// *****************************************************************************
// Copyright (C) 2023-2024 STMicroelectronics.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: MIT License which is
// available at https://opensource.org/licenses/MIT.
//
// SPDX-License-Identifier: EPL-2.0 OR MIT
// *****************************************************************************

import chai, { expect } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import chaiLike from 'chai-like';
import type { Operation } from 'fast-json-patch';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import type { Command, CompoundCommand, SimpleCommand } from '../command';
import {
  append,
  CommandReturnResult,
  CompoundCommandImpl,
  FailedResult,
  groupByModelId,
  isCompoundCommand,
  isSimpleCommandWithResult,
  ReadyResult,
  unwrapReturnResult,
} from '../command';

chai.use(chaiLike);
chai.use(chaiAsPromised);
chai.use(sinonChai);

describe('Command-related functions', () => {
  describe('isCompoundCommand', () => {
    it('detects a CompoundCommand', async () => {
      const compound = new CompoundCommandImpl('test');
      const isCompound = isCompoundCommand(compound);

      expect(isCompound, 'compound command not detected').to.be.true;
    });

    it('detects a simple (non-compound) Command', async () => {
      const leaf = new TestCommand('test');
      const isCompound = isCompoundCommand(leaf);

      expect(isCompound, 'simple command not detected').to.be.false;
    });
  });

  describe('append', () => {
    it('base is already a CompoundCommand', async () => {
      const compound = new CompoundCommandImpl('test');
      const appended = new TestCommand('appended');
      const result = append(compound, appended);

      expect(result, 'compound command not appended').to.be.equal(compound);
      expect(result).to.be.like({ _commands: [appended] });
    });

    it('base is not a CompoundCommand', async () => {
      const base = new TestCommand('test');
      const appended = new TestCommand('appended');
      const result = append(base, appended);

      expect(result, 'commands not compounded').to.be.instanceOf(
        CompoundCommandImpl
      );
      expect(result).to.be.like({ _commands: [base, appended] });
    });

    it('is given nothing to append', async () => {
      const base = new TestCommand('test');
      const result = append(base);

      expect(result, 'command was unnecessarily replaced').to.be.equal(base);
    });
  });

  describe('groupByModelId', () => {
    it('group compound command results by model', async () => {
      const models = new Map<string, object>([
        ['model1', { id: 'model1' }],
        ['model2', { id: 'model2' }],
      ]);
      const command1 = new TestCommand('testProp1', 'model1');
      const command2 = new TestCommand('testProp2', 'model1');
      const command3 = new TestCommand('test', 'model2');
      const compoundModel1 = new CompoundCommandImpl(
        'test',
        command1,
        command2
      );
      const compoundModel2 = new CompoundCommandImpl('test', command3);
      const topCompound = new CompoundCommandImpl(
        'test',
        compoundModel1,
        compoundModel2
      );

      const result = await topCompound.execute(models.get.bind(models));
      expect(result).to.not.be.undefined;
      if (result !== undefined) {
        const groupedResult = groupByModelId(result);
        expect(groupedResult.size).to.equal(2);
      }
    });
  });

  describe('isSimpleCommandWithResult', () => {
    it('is not', () => {
      const command = new TestCommand('test', 'model1');
      const isIt = isSimpleCommandWithResult(command);

      expect(isIt).to.be.false;
    });

    it('is', () => {
      const command = new TestCommandWithResult('test', 'model1');
      const isIt = isSimpleCommandWithResult(command);

      expect(isIt).to.be.true;
    });
  });

  describe('unwrapReturnResult', () => {
    it('pending', () => {
      const command = new TestCommandWithResult('test', 'model1');
      const unwrapped = unwrapReturnResult(command);

      expect(unwrapped).to.be.undefined;
    });

    it('failed', () => {
      const command = Object.assign(new TestCommand('test', 'model1'), {
        result: {
          status: 'failed',
          error: new Error('💣'),
        } satisfies FailedResult,
      });
      expect(() => unwrapReturnResult(command)).to.throw('💣');
    });

    it('failed without detail', () => {
      const command = Object.assign(new TestCommand('test', 'model1'), {
        result: {
          status: 'failed',
        } satisfies FailedResult,
      });
      expect(() => unwrapReturnResult(command)).to.throw();
    });

    it('ready', () => {
      const command = Object.assign(new TestCommand('test', 'model1'), {
        result: {
          status: 'ready',
          value: 'Hello, world!',
        } satisfies ReadyResult<string>,
      });
      const unwrapped = unwrapReturnResult(command);

      expect(unwrapped).to.be.eq('Hello, world!');
    });
  });

  // This test ensures a wider test coverage, by crafting test cases that
  // are not expected to happen in a more realistic scenario
  it('additional corner cases', () => {
    const command1 = new TestCommand('testProp2', 'model1');
    const command2 = new TestCommand('test', 'model2');
    const compound = new CompoundCommandImpl('test', command1, command2);

    const result = new Map<Command, Operation[]>();
    result.set(command1, undefined as unknown as Operation[]);
    result.set(command2, []);
    result.set(compound, []);

    const groupedResult = groupByModelId(result);
    expect(groupedResult.size).to.equal(1);
    expect(groupedResult.get('model1')).to.be.undefined;
    expect(groupedResult.get('model2')).to.be.an('array').that.is.empty;
  });
});

describe('CompoundCommandImpl', () => {
  let getModel: (modelId: string) => object | undefined;
  let commands: Command[];
  let compound: CompoundCommandImpl;

  const forceAppend = (
    compound: CompoundCommandImpl,
    ...commands: Command[]
  ): void => {
    // Cannot append via the API, so shoehorn it
    (compound as unknown as { _commands: Command[] })._commands.push(
      ...commands
    );
  };

  beforeEach(() => {
    getModel = () => ({});
    commands = [new AsyncTestCommand('a'), new AsyncTestCommand('b')];
    compound = new CompoundCommandImpl('test', ...commands);
  });

  it('has a label', () => {
    expect(compound.label, 'wrong label').to.be.eq('test');
  });

  describe('execute', () => {
    let sandbox: sinon.SinonSandbox;

    beforeEach(() => {
      sandbox = sinon.createSandbox();
    });

    afterEach(() => {
      sandbox.restore();
    });

    it('executes the subcommands', async () => {
      await compound.execute(getModel);

      expect(commands, 'commands not executed').to.be.like([
        { wasExecuted: true },
        { wasExecuted: true },
      ]);
    });

    it('collates the deltas', async () => {
      const delta = await compound.execute(getModel);

      expect(delta, 'no delta returned').to.exist;
      expect(delta?.get(commands[0]), 'wrong result for command a').to.be.like([
        { op: 'add', path: 'a' },
      ]);
      expect(delta?.get(commands[1]), 'wrong result for command b').to.be.like([
        { op: 'add', path: 'b' },
      ]);
    });

    it('rewinds subcommands on failure', async () => {
      const third = new AsyncTestCommand('c');
      commands.push(third);
      compound.append(third);

      third.failOn('execute');

      await expect(compound.execute(getModel)).to.eventually.have.rejected;

      expect(commands, 'commands not rewound').to.be.like([
        { wasUndone: true, failed: false },
        { wasUndone: true, failed: false },
        { wasExecuted: false, failed: true },
      ]);
    });

    it('best-effort rewinds in case of rewind failure', async () => {
      const third = new AsyncTestCommand('c');
      commands.push(third);
      compound.append(third);

      third.failOn('execute');
      (commands[1] as AsyncTestCommand).failOn('undo');

      const consoleError = sandbox.stub(console, 'error');
      await expect(compound.execute(getModel)).to.eventually.have.rejected;

      expect(consoleError).to.have.been.calledWithMatch(
        'model-manager/command:',
        'Error in recovery of failed execute.'
      );
      expect(commands, 'commands not rewound').to.be.like([
        { wasUndone: true, failed: false },
        { wasUndone: false, failed: true },
        { wasExecuted: false, failed: true },
      ]);
    });

    describe('asserts the preconditions', () => {
      it('is non-executable by itself', async () => {
        // Make it non-executable
        await compound.execute(getModel);

        await expect(
          compound.execute(getModel),
          'compound should not have executed'
        ).to.eventually.be.rejected;
      });

      it('is non-executable by a nested command', async () => {
        // Make it non-executable
        await commands[1].execute(getModel);

        await expect(
          compound.execute(getModel),
          'compound should not have executed'
        ).to.eventually.be.rejected;
      });
    });
  });

  describe('undo', () => {
    beforeEach(() => {
      return compound.execute(getModel);
    });

    it('undoes the subcommands', async () => {
      await compound.undo(getModel);

      expect(commands, 'commands not undone').to.be.like([
        { wasUndone: true },
        { wasUndone: true },
      ]);
    });

    it('collates the deltas', async () => {
      const delta = await compound.undo(getModel);

      expect(delta, 'no delta returned').to.exist;
      expect(delta?.get(commands[0]), 'wrong result for command a').to.be.like([
        { op: 'remove', path: 'a' },
      ]);
      expect(delta?.get(commands[1]), 'wrong result for command b').to.be.like([
        { op: 'remove', path: 'b' },
      ]);
    });

    it('rewinds subcommands on failure', async () => {
      const third = new AsyncTestCommand('c');
      await third.execute();

      commands.push(third);
      forceAppend(compound, third);

      (commands[0] as AsyncTestCommand).failOn('undo');

      await expect(compound.undo(getModel)).to.eventually.have.rejected;

      expect(commands, 'commands not rewound').to.be.like([
        { wasUndone: false, failed: true },
        { wasRedone: true, failed: false },
        { wasRedone: true, failed: false },
      ]);
    });

    describe('asserts the preconditions', () => {
      it('is non-undoable by itself', async () => {
        // Make it non-undoable
        await compound.undo(getModel);

        await expect(compound.undo(getModel), 'compound should not have undone')
          .to.eventually.be.rejected;
      });

      it('is non-undoable by a nested command', async () => {
        // Make it non-undoable
        await commands[0].undo(getModel);

        await expect(compound.undo(getModel), 'compound should not have undone')
          .to.eventually.be.rejected;
      });
    });
  });

  describe('redo', () => {
    beforeEach(async () => {
      await compound.execute(getModel);
      await compound.undo(getModel);
    });

    it('redoes the subcommands', async () => {
      await compound.redo(getModel);

      expect(commands, 'commands not redone').to.be.like([
        { wasRedone: true },
        { wasRedone: true },
      ]);
    });

    it('collates the deltas', async () => {
      const delta = await compound.redo(getModel);

      expect(delta, 'no delta returned').to.exist;
      expect(delta?.get(commands[0]), 'wrong result for command a').to.be.like([
        { op: 'add', path: 'a' },
      ]);
      expect(delta?.get(commands[1]), 'wrong result for command b').to.be.like([
        { op: 'add', path: 'b' },
      ]);
    });

    describe('asserts the preconditions', () => {
      it('is non-redoable by itself', async () => {
        // Make it non-redoable
        await compound.redo(getModel);

        await expect(compound.redo(getModel), 'compound should not have redone')
          .to.eventually.be.rejected;
      });

      it('is non-redoable by a nested command', async () => {
        // Make it non-redoable
        await commands[1].redo(getModel);

        await expect(compound.redo(getModel), 'compound should not have redone')
          .to.eventually.be.rejected;
      });
    });
  });

  describe('canExecute', () => {
    let sandbox: sinon.SinonSandbox;

    beforeEach(() => {
      sandbox = sinon.createSandbox();
    });

    afterEach(() => {
      sandbox.restore();
    });

    it('is true', () => {
      return expect(compound.canExecute(getModel), 'compound not executable').to
        .eventually.be.true;
    });

    it('is true with logs by reason of being an empty compound', async () => {
      const spyConsoleDebug = sandbox.spy(console, 'debug');
      const empty = new CompoundCommandImpl('empty');
      const result = await empty.canExecute(getModel);
      expect(result, 'empty compound is executable').to.be.true;
      expect(spyConsoleDebug).to.have.been.calledWithMatch(
        'model-manager/command:',
        `No constituent command for ${empty.label}.`
      );
    });

    it('is false by reason of itself', async () => {
      await compound.execute(getModel); // Make it non-executable by executing it

      return expect(compound.canExecute(getModel), 'compound is executable').to
        .eventually.be.false;
    });

    describe('asynchronous commands', () => {
      it('is false by reason of a nested command', async () => {
        await commands[1].execute(getModel); // Make one non-executable by executing it

        return expect(compound.canExecute(getModel), 'compound is executable')
          .to.eventually.be.false;
      });
    });

    describe('synchronous commands', () => {
      beforeEach(() => {
        commands = [new TestCommand('a'), new TestCommand('b')];
        compound = new CompoundCommandImpl('test', ...commands);
      });

      it('is false by reason of a nested command', () => {
        commands[1].execute(getModel); // Make one non-executable by executing it

        return expect(compound.canExecute(getModel), 'compound is executable')
          .to.eventually.be.false;
      });
    });
  });

  describe('canUndo', () => {
    let sandbox: sinon.SinonSandbox;

    beforeEach(() => {
      sandbox = sinon.createSandbox();
      return compound.execute(getModel);
    });

    afterEach(() => {
      sandbox.restore();
    });

    it('is true', () => {
      return expect(compound.canUndo(getModel), 'compound not undoable').to
        .eventually.be.true;
    });

    it('is false by reason of itself', async () => {
      // Make it non-undoable by undoing it
      await compound.undo(getModel);

      return expect(compound.canUndo(getModel), 'compound is undoable').to
        .eventually.be.false;
    });

    describe('asynchronous commands', () => {
      it('is false by reason of a nested command', async () => {
        // Make one non-undoable by undoing it
        await commands[0].undo(getModel);

        return expect(compound.canUndo(getModel), 'compound is undoable').to
          .eventually.be.false;
      });
    });

    describe('synchronous commands', () => {
      beforeEach(() => {
        commands = [new TestCommand('a'), new TestCommand('b')];
        compound = new CompoundCommandImpl('test', ...commands);
        return compound.execute(getModel);
      });

      it('is false by reason of a nested command', () => {
        commands[0].undo(getModel); // Make one non-undoable by executing it

        return expect(compound.canUndo(getModel), 'compound is undoable').to
          .eventually.be.false;
      });
    });

    describe('no sub commands', () => {
      beforeEach(() => {
        sandbox = sinon.createSandbox();
        compound = new CompoundCommandImpl('test');
        return compound.execute(getModel);
      });

      afterEach(() => {
        sandbox.restore();
      });

      it('is true even though no command', async () => {
        const spyConsoleDebug = sandbox.spy(console, 'debug');
        const result = await compound.canUndo(getModel);
        expect(result, 'compound is not undoable').to.be.true;
        expect(spyConsoleDebug).to.have.been.calledWithMatch(
          'model-manager/command:',
          `No constituent command for ${compound.label}.`
        );
      });
    });
  });

  describe('canRedo', () => {
    let sandbox: sinon.SinonSandbox;

    beforeEach(async () => {
      sandbox = sinon.createSandbox();
      await compound.execute(getModel);
      await compound.undo(getModel);
    });

    afterEach(() => {
      sandbox.restore();
    });

    it('is true', () => {
      return expect(compound.canRedo(getModel), 'compound not redoable').to
        .eventually.be.true;
    });

    it('is false by reason of itself', async () => {
      // Make it non-undoable by redoing it
      await compound.redo(getModel);

      return expect(compound.canRedo(getModel), 'compound is redoable').to
        .eventually.be.false;
    });

    describe('asynchronous commands', () => {
      it('is false by reason of a nested command', async () => {
        // Make one non-undoable by redoing it
        await commands[1].redo(getModel);

        return expect(compound.canRedo(getModel), 'compound is redoable').to
          .eventually.be.false;
      });
    });

    describe('synchronous commands', () => {
      beforeEach(async () => {
        commands = [new TestCommand('a'), new TestCommand('b')];
        compound = new CompoundCommandImpl('test', ...commands);
        await compound.execute(getModel);
        await compound.undo(getModel);
      });

      it('is false by reason of a nested command', () => {
        // Make one non-redoable by redoing it
        commands[1].redo(getModel);

        return expect(compound.canRedo(getModel), 'compound is redoable').to
          .eventually.be.false;
      });
    });

    describe('no sub commands', () => {
      beforeEach(async () => {
        compound = new CompoundCommandImpl('test');
        await compound.execute(getModel);
        await compound.undo(getModel);
      });

      it('is true even though no command', async () => {
        const spyConsoleDebug = sandbox.spy(console, 'debug');
        const result = await compound.canRedo(getModel);
        expect(result, 'compound is not redoable').to.be.true;
        expect(spyConsoleDebug).to.have.been.calledWithMatch(
          'model-manager/command:',
          `No constituent command for ${compound.label}.`
        );
      });
    });
  });

  describe('append', () => {
    let newCommand: Command;

    beforeEach(() => {
      newCommand = new AsyncTestCommand('c');
    });

    it('is appendable', () => {
      compound.append(newCommand);

      expect(compound, 'new command not appended').to.be.like({
        _commands: [{ label: 'a' }, { label: 'b' }, { label: 'c' }],
      });
    });

    it('is frozen', async () => {
      await compound.execute(getModel);

      expect(
        () => compound.append(newCommand),
        'should have thrown precondition error'
      ).to.throw();
    });
  });

  describe('getCommands', () => {
    it('returns the commands', () => {
      const list = compound.getCommands();

      expect(list, 'wrong commands returned').to.be.like([
        { label: 'a' },
        { label: 'b' },
      ]);
    });

    it('returns a safe copy', () => {
      const list = compound.getCommands();
      list.splice(0, 1);

      expect(compound, 'command removed from the compound').to.be.like({
        _commands: [{ label: 'a' }, { label: 'b' }],
      });
    });
  });

  describe('nested compounds', () => {
    let nested: Command[];
    let nestedCompound: CompoundCommand;
    let sandbox: sinon.SinonSandbox;

    const obtrudeExecute = (cmd: Command) => {
      const stub = sandbox.stub(cmd, 'execute').callsFake((...args) => {
        stub.wrappedMethod.apply(cmd, args);
        return undefined;
      });
    };

    beforeEach(() => {
      nested = [new AsyncTestCommand('c'), new AsyncTestCommand('d')];
      nestedCompound = new CompoundCommandImpl('nested', ...nested);
      compound.append(nestedCompound);
      sandbox = sinon.createSandbox();
    });

    afterEach(() => {
      sandbox.restore();
    });

    it('collates the nested deltas', async () => {
      const delta = await compound.execute(getModel);

      expect(delta, 'no delta returned').to.exist;
      expect(delta?.size, 'wrong number of deltas returned').to.be.eq(4);
      expect(delta?.get(nested[0])).to.be.like([{ op: 'add', path: 'c' }]);
      expect(delta?.get(nested[1])).to.be.like([{ op: 'add', path: 'd' }]);
      expect(delta?.has(commands[0])).to.be.true;
      expect(delta?.has(commands[1])).to.be.true;
    });

    it('returns partial results', async () => {
      obtrudeExecute(nested[0]);
      commands.forEach((cmd) => sandbox.spy(cmd, 'execute'));

      const delta = await compound.execute(getModel);

      expect(delta, 'a partial delta was not returned').to.exist;
      expect(delta?.get(nested[1])).to.be.like([{ op: 'add', path: 'd' }]);
      expect(Array.from(delta?.keys() ?? [])).to.include.members(commands);

      commands.forEach((cmd) => expect(cmd.execute).to.have.been.called);
    });

    it('returns undefined if no results', async () => {
      nested.forEach(obtrudeExecute);
      commands.forEach(obtrudeExecute);

      const delta = await compound.execute(getModel);

      expect(delta, 'a delta was returned').not.to.exist;

      nested.forEach((cmd) => expect(cmd.execute).to.have.been.called);
      commands.forEach((cmd) => expect(cmd.execute).to.have.been.called);
    });
  });

  describe('iteration', () => {
    it('empty compound', () => {
      const labels = new CompoundCommandImpl('test').map((cmd) => cmd.label);
      expect(labels).to.eql([]);
    });

    it('flat compound', () => {
      const compound = new CompoundCommandImpl(
        'test',
        new TestCommand('a'),
        new TestCommand('b')
      );

      const labels = compound.map((cmd) => cmd.label);
      expect(labels).to.eql(['a', 'b']);
    });

    it('nested compound', () => {
      const compound = new CompoundCommandImpl(
        'test',
        new CompoundCommandImpl(
          'one',
          new TestCommand('a'),
          new TestCommand('b')
        ),
        new TestCommand('c'),
        new CompoundCommandImpl(
          'two',
          new TestCommand('d'),
          new CompoundCommandImpl(
            'three',
            new TestCommand('e'),
            new TestCommand('f')
          ),
          new TestCommand('g')
        )
      );

      const labels = compound.map((cmd) => cmd.label);
      expect(labels).to.eql(['a', 'b', 'c', 'd', 'e', 'f', 'g']);
    });
  });

  describe('Compounds of compounds', () => {
    beforeEach(async () => {
      commands = [
        new AsyncTestCommand('a', 'a'),
        new TestCommand('b'),
        new AsyncTestCommand('c', 'c'),
      ];
      compound = new CompoundCommandImpl(
        'outer',
        commands[0],
        new CompoundCommandImpl('inner', ...commands.slice(1))
      );
    });

    it('execute', async () => {
      await compound.execute(getModel);
      expect(commands, 'commands not executed').to.be.like([
        { wasExecuted: true },
        { wasExecuted: true },
        { wasExecuted: true },
      ]);
    });

    it('undo', async () => {
      await compound.execute(getModel);
      await compound.undo(getModel);

      expect(commands, 'commands not undone').to.be.like([
        { wasUndone: true },
        { wasUndone: true },
        { wasUndone: true },
      ]);
    });

    it('redo', async () => {
      await compound.execute(getModel);
      await compound.undo(getModel);
      await compound.redo(getModel);

      expect(commands, 'commands not redone').to.be.like([
        { wasRedone: true },
        { wasRedone: true },
        { wasRedone: true },
      ]);
    });

    it('rewinds subcommands on failure', async () => {
      await compound.execute(getModel);
      const fourth = new AsyncTestCommand('d');
      await fourth.execute();

      commands.push(fourth);
      forceAppend(compound, fourth);

      (commands[0] as AsyncTestCommand).failOn('undo');

      await expect(compound.undo(getModel)).to.eventually.have.rejected;

      expect(commands, 'commands not rewound').to.be.like([
        { wasUndone: false, failed: true },
        { wasRedone: true },
        { wasRedone: true },
        { wasRedone: true },
      ]);
    });

    it('model AWOL on execute', async () => {
      let countdown = 2;
      getModel = (modelId: string) => {
        if (modelId === 'c' && countdown-- <= 0) {
          return undefined;
        }
        return {};
      };
      await expect(compound.execute(getModel)).to.eventually.have.rejectedWith(
        'No model on which to execute'
      );
    });

    it('model AWOL on rewind', async () => {
      const bomb = new AsyncTestCommand('Boom!');
      bomb.failOn('execute');
      commands.push(bomb);
      forceAppend(compound, bomb);

      let countdown = 2;
      getModel = (modelId: string) => {
        if (modelId === 'a' && countdown-- <= 0) {
          return undefined;
        }
        return {};
      };

      // The exception from rewind isn't actually surfaced
      await expect(compound.execute(getModel)).to.eventually.have.rejected;
      expect(commands[1]).to.be.like({ wasUndone: true });
      expect(commands[0]).to.be.like({ wasUndone: false });
    });
  });
});

class TestCommand implements SimpleCommand {
  constructor(public readonly label: string, public readonly modelId = '') {}

  public wasExecuted = false;
  public wasUndone = false;
  public wasRedone = false;

  canExecute(): boolean {
    return !this.wasExecuted && !this.wasUndone && !this.wasRedone;
  }

  canUndo(): boolean {
    return this.wasExecuted || this.wasRedone;
  }

  canRedo(): boolean {
    return this.wasUndone;
  }

  execute(): Operation[] | undefined {
    if (!this.canExecute()) {
      throw new Error('cannot execute');
    }
    this.wasExecuted = true;
    this.wasUndone = false;
    this.wasRedone = false;
    return [{ op: 'add', path: this.label, value: 'test-value' }];
  }

  undo(): Operation[] | undefined {
    if (!this.canUndo()) {
      throw new Error('cannot undo');
    }
    this.wasExecuted = false;
    this.wasUndone = true;
    this.wasRedone = false;
    return [{ op: 'remove', path: this.label }];
  }

  redo(): Operation[] | undefined {
    if (!this.canRedo()) {
      throw new Error('cannot redo');
    }
    this.wasExecuted = false;
    this.wasUndone = false;
    this.wasRedone = true;
    return [{ op: 'add', path: this.label, value: 'test-value' }];
  }
}

class AsyncTestCommand implements SimpleCommand {
  constructor(public readonly label: string, public readonly modelId = '') {}

  public wasExecuted = false;
  public wasUndone = false;
  public wasRedone = false;

  private failOnOp?: keyof SimpleCommand;
  public failed = false;

  failOn(op: keyof SimpleCommand): void {
    this.failOnOp = op;
  }

  async canExecute(): Promise<boolean> {
    this.maybeFailOn('canExecute');
    return !this.wasExecuted && !this.wasUndone && !this.wasRedone;
  }

  async canUndo(): Promise<boolean> {
    this.maybeFailOn('canUndo');
    return this.wasExecuted || this.wasRedone;
  }

  async canRedo(): Promise<boolean> {
    this.maybeFailOn('canRedo');
    return this.wasUndone;
  }

  async execute(): Promise<Operation[] | undefined> {
    if (!this.canExecute()) {
      throw new Error('cannot execute');
    }
    this.maybeFailOn('execute');

    this.wasExecuted = true;
    this.wasUndone = false;
    this.wasRedone = false;

    return [{ op: 'add', path: this.label, value: 'test-value' }];
  }

  async undo(): Promise<Operation[] | undefined> {
    if (!this.canUndo()) {
      throw new Error('cannot undo');
    }
    this.maybeFailOn('undo');

    this.wasExecuted = false;
    this.wasUndone = true;
    this.wasRedone = false;

    return [{ op: 'remove', path: this.label }];
  }

  async redo(): Promise<Operation[] | undefined> {
    if (!this.canRedo()) {
      throw new Error('cannot redo');
    }
    this.maybeFailOn('redo');

    this.wasExecuted = false;
    this.wasUndone = false;
    this.wasRedone = true;

    return [{ op: 'add', path: this.label, value: 'test-value' }];
  }

  private maybeFailOn(op: keyof SimpleCommand): void {
    if (this.failOnOp === op) {
      this.failed = true;
      this.failOnOp = undefined;
      throw new Error(`Failed on ${op} as directed.`);
    }
  }
}

class TestCommandWithResult extends TestCommand {
  result: CommandReturnResult<unknown> = { status: 'pending' };
}
