import type { ObjectHydrator } from '@mikro-orm/core';
import { Embeddable, Embedded, Entity, Enum, MikroORM, OptionalProps, PrimaryKey, Property, wrap } from '@mikro-orm/core';
import { TsMorphMetadataProvider } from '@mikro-orm/reflection';
import { mockLogger } from '../../helpers';
import { SqliteDriver } from '@mikro-orm/sqlite';

enum AnimalType {
  CAT,
  DOG,
}

@Embeddable({ abstract: true, discriminatorColumn: 'type' })
abstract class Animal {

  @Enum()
  type!: AnimalType;

  @Property()
  name!: string;

}

@Embeddable({ discriminatorValue: AnimalType.CAT })
class Cat extends Animal {

  @Property()
  canMeow? = true;

  constructor(name: string) {
    super();
    this.type = AnimalType.CAT;
    this.name = name;
  }

}

@Embeddable({ discriminatorValue: AnimalType.DOG })
class Dog extends Animal {

  @Property()
  canBark? = true;

  constructor(name: string) {
    super();
    this.type = AnimalType.DOG;
    this.name = name;
  }

}

@Entity()
class Owner {

  [OptionalProps]?: 'pets';

  @PrimaryKey()
  id!: number;

  @Property()
  name: string;

  @Embedded()
  pet!: Cat | Dog;

  @Embedded({ object: true })
  pet2!: Cat | Dog;

  @Embedded()
  pets: (Cat | Dog)[] = [];

  constructor(name: string) {
    this.name = name;
  }

}

describe('polymorphic embeddables in sqlite', () => {

  let orm: MikroORM;

  beforeAll(async () => {
    orm = await MikroORM.init({
      entities: [Dog, Cat, Owner],
      dbName: ':memory:',
      driver: SqliteDriver,
      metadataProvider: TsMorphMetadataProvider,
    });
    await orm.schema.createSchema();
  });

  afterAll(async () => {
    await orm.close();
  });

  beforeEach(async () => {
    await orm.em.nativeDelete(Owner, {});
    orm.em.clear();
  });

  test(`schema`, async () => {
    await expect(orm.schema.getCreateSchemaSQL({ wrap: false })).resolves.toMatchSnapshot();
    await expect(orm.schema.getUpdateSchemaSQL({ wrap: false })).resolves.toMatchSnapshot();
    await expect(orm.schema.getDropSchemaSQL({ wrap: false })).resolves.toMatchSnapshot();
  });

  test(`diffing`, async () => {
    const hydrator = orm.config.getHydrator(orm.getMetadata()) as ObjectHydrator;
    expect(hydrator.getEntityHydrator(orm.getMetadata().get('Owner'), 'full').toString()).toMatchSnapshot();
    expect(orm.em.getComparator().getSnapshotGenerator('Owner').toString()).toMatchSnapshot();
  });

  test(`working with polymorphic embeddables`, async () => {
    const ent1 = new Owner('o1');
    ent1.pet = new Dog('d1');
    ent1.pet2 = new Cat('c3');
    ent1.pets.push(ent1.pet, ent1.pet2);
    expect(ent1.pet).toBeInstanceOf(Dog);
    expect((ent1.pet as Dog).canBark).toBe(true);
    expect(ent1.pet2).toBeInstanceOf(Cat);
    expect((ent1.pet2 as Cat).canMeow).toBe(true);
    const ent2 = orm.em.create(Owner, {
      name: 'o2',
      pet: { type: AnimalType.CAT, name: 'c1' },
      pet2: { type: AnimalType.DOG, name: 'd4' },
      pets: [
        { type: AnimalType.CAT, name: 'cc1' },
        { type: AnimalType.DOG, name: 'dd4' },
      ],
    });
    expect(ent2.pet).toBeInstanceOf(Cat);
    expect((ent2.pet as Cat).canMeow).toBe(true);
    expect(ent2.pet2).toBeInstanceOf(Dog);
    expect((ent2.pet2 as Dog).canBark).toBe(true);
    expect(ent2.pets[0]).toBeInstanceOf(Cat);
    expect((ent2.pets[0] as Cat).canMeow).toBe(true);
    expect(ent2.pets[1]).toBeInstanceOf(Dog);
    expect((ent2.pets[1] as Dog).canBark).toBe(true);
    const ent3 = orm.em.create(Owner, {
      name: 'o3',
      pet: { type: AnimalType.DOG, name: 'd2' },
      pet2: { type: AnimalType.CAT, name: 'c4' },
    });
    expect(ent3.pet).toBeInstanceOf(Dog);
    expect((ent3.pet as Dog).canBark).toBe(true);
    expect(ent3.pet2).toBeInstanceOf(Cat);
    expect((ent3.pet2 as Cat).canMeow).toBe(true);

    const mock = mockLogger(orm, ['query']);
    await orm.em.persistAndFlush([ent1, ent2, ent3]);
    expect(mock.mock.calls[0][0]).toMatch('begin');
    expect(mock.mock.calls[1][0]).toMatch('insert into `owner` (`name`, `pet_type`, `pet_name`, `pet_can_meow`, `pet2`, `pets`, `pet_can_bark`) values (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?)');
    expect(mock.mock.calls[2][0]).toMatch('commit');
    orm.em.clear();

    const owners = await orm.em.find(Owner, {}, { orderBy: { name: 1 } });
    expect(mock.mock.calls[3][0]).toMatch('select `o0`.* from `owner` as `o0` order by `o0`.`name` asc');
    expect(owners[0].name).toBe('o1');
    expect(owners[0].pet).toBeInstanceOf(Dog);
    expect(owners[0].pet.name).toBe('d1');
    expect(owners[0].pet.type).toBe(AnimalType.DOG);
    expect((owners[0].pet as Cat).canMeow).toBeNull();
    expect((owners[0].pet as Dog).canBark).toBe(true);
    expect(owners[0].pets[0]).toBeInstanceOf(Dog);
    expect(owners[0].pets[0].name).toBe('d1');
    expect(owners[0].pets[0].type).toBe(AnimalType.DOG);
    expect((owners[0].pets[0] as Cat).canMeow).toBeUndefined();
    expect((owners[0].pets[0] as Dog).canBark).toBe(true);
    expect(owners[0].pets[0]).not.toBe(owners[0].pet); // no identity map for embeddables as they don't have PKs
    expect(owners[0].pets[1]).not.toBe(owners[0].pet2); // no identity map for embeddables as they don't have PKs
    expect(owners[0].pets).toMatchObject([
      { canBark: true, name: 'd1', type: 1 },
      { canMeow: true, name: 'c3', type: 0 },
    ]);
    expect(owners[1].pet).toBeInstanceOf(Cat);
    expect(owners[1].pet.name).toBe('c1');
    expect(owners[1].pet.type).toBe(AnimalType.CAT);
    expect((owners[1].pet as Cat).canMeow).toBe(true);
    expect((owners[1].pet as Dog).canBark).toBeNull();
    expect(owners[1].pets).toMatchObject([
      { canMeow: true, name: 'cc1', type: 0 },
      { canBark: true, name: 'dd4', type: 1 },
    ]);
    expect(owners[2].pet).toBeInstanceOf(Dog);
    expect(owners[2].pet.name).toBe('d2');
    expect(owners[2].pet.type).toBe(AnimalType.DOG);
    expect(owners[2].pets).toEqual([]);

    expect(mock.mock.calls).toHaveLength(4);
    await orm.em.flush();
    expect(mock.mock.calls).toHaveLength(4);

    owners[0].pet = new Cat('c2');
    owners[0].pets[0].name = 'new dog name';
    owners[0].pets[1].name = 'new cat name';
    owners[0].pets.push(new Dog('added dog'));
    owners[1].pet = new Dog('d3');
    owners[2].pet.name = 'old dog';
    mock.mock.calls.length = 0;
    await orm.em.flush();
    expect(mock.mock.calls).toHaveLength(3);
    expect(mock.mock.calls[0][0]).toMatch('begin');
    expect(mock.mock.calls[1][0]).toMatch('update `owner` set `pet_can_bark` = case when (`id` = ?) then ? when (`id` = ?) then ? else `pet_can_bark` end, `pet_type` = case when (`id` = ?) then ? when (`id` = ?) then ? else `pet_type` end, `pet_name` = case when (`id` = ?) then ? when (`id` = ?) then ? when (`id` = ?) then ? else `pet_name` end, `pet_can_meow` = case when (`id` = ?) then ? when (`id` = ?) then ? else `pet_can_meow` end, `pets` = case when (`id` = ?) then ? else `pets` end where `id` in (?, ?, ?)');
    expect(mock.mock.calls[2][0]).toMatch('commit');
    orm.em.clear();

    const owners2 = await orm.em.find(Owner, {}, { orderBy: { name: 1 } });
    expect(mock.mock.calls[3][0]).toMatch('select `o0`.* from `owner` as `o0` order by `o0`.`name` asc');

    expect(owners2[0].name).toBe('o1');
    expect(owners2[0].pet).toBeInstanceOf(Cat);
    expect(owners2[0].pet.name).toBe('c2');
    expect(owners2[0].pet.type).toBe(AnimalType.CAT);
    expect(owners2[0].pets[0].name).toBe('new dog name');
    expect(owners2[0].pets[1].name).toBe('new cat name');
    expect(owners2[0].pets[2].name).toBe('added dog');
    expect((owners2[0].pets[0] as Dog).canBark).toBe(true);
    expect((owners2[0].pets[1] as Cat).canMeow).toBe(true);
    expect((owners2[0].pets[2] as Dog).canBark).toBe(true);
    expect((owners2[0].pet as Dog).canBark).toBeNull();
    expect((owners2[0].pet as Cat).canMeow).toBe(true);

    expect(owners2[1].pet).toBeInstanceOf(Dog);
    expect(owners2[1].pet.name).toBe('d3');
    expect(owners2[1].pet.type).toBe(AnimalType.DOG);
    expect((owners2[1].pet as Dog).canBark).toBe(true);
    expect((owners2[1].pet as Cat).canMeow).toBeNull();

    expect(owners2[2].pet).toBeInstanceOf(Dog);
    expect(owners2[2].pet.name).toBe('old dog');
    expect(owners2[2].pet.type).toBe(AnimalType.DOG);
    expect((owners2[2].pet as Dog).canBark).toBe(true);
    expect((owners2[2].pet as Cat).canMeow).toBeNull();
    orm.em.clear();

    mock.mock.calls.length = 0;
    const dogOwners = await orm.em.find(Owner, { pet: { name: ['d3', 'old dog'] } }, { orderBy: { name: 1 } });
    const dogOwners2 = await orm.em.find(Owner, { pet2: { name: ['d4', 'c4'] } }, { orderBy: { name: 1 } });
    const dogOwners3 = await orm.em.find(Owner, { $or: [
      { pet: { name: ['d3', 'old dog'] } },
      { pet2: { name: ['d4', 'c4'] } },
    ] }, { orderBy: { name: 1 } });

    const check = (items: Owner[]) => {
      expect(items).toHaveLength(2);
      expect(items[0].pet).toBeInstanceOf(Dog);
      expect(items[0].pet.name).toBe('d3');
      expect(items[0].pet2).toBeInstanceOf(Dog);
      expect(items[0].pet2.name).toBe('d4');
      expect(items[1].pet).toBeInstanceOf(Dog);
      expect(items[1].pet.name).toBe('old dog');
      expect(items[1].pet2).toBeInstanceOf(Cat);
      expect(items[1].pet2.name).toBe('c4');
    };
    check(dogOwners);
    check(dogOwners2);
    check(dogOwners3);

    expect(mock.mock.calls[0][0]).toMatch('select `o0`.* from `owner` as `o0` where `o0`.`pet_name` in (?, ?) order by `o0`.`name` asc');
    expect(mock.mock.calls[1][0]).toMatch('select `o0`.* from `owner` as `o0` where json_extract(`o0`.`pet2`, \'$.name\') in (?, ?) order by `o0`.`name` asc');
    expect(mock.mock.calls[2][0]).toMatch('select `o0`.* from `owner` as `o0` where (`o0`.`pet_name` in (?, ?) or json_extract(`o0`.`pet2`, \'$.name\') in (?, ?)) order by `o0`.`name` asc');
  });

  test('assign and serialization', async () => {
    const owner = new Owner('o1');
    orm.em.assign(owner, {
      pet: { name: 'cat', type: AnimalType.CAT },
      pet2: { name: 'dog', type: AnimalType.DOG },
      pets: [
        { name: 'dog in array', type: AnimalType.DOG },
        { name: 'cat in array', type: AnimalType.CAT },
      ],
    });
    expect(owner.pet).toMatchObject({ name: 'cat', type: AnimalType.CAT });
    expect(owner.pet).toBeInstanceOf(Cat);
    expect(owner.pet2).toMatchObject({ name: 'dog', type: AnimalType.DOG });
    expect(owner.pet2).toBeInstanceOf(Dog);
    expect(owner.pets[0]).toMatchObject({ name: 'dog in array', type: AnimalType.DOG });
    expect(owner.pets[0]).toBeInstanceOf(Dog);
    expect(owner.pets[1]).toMatchObject({ name: 'cat in array', type: AnimalType.CAT });
    expect(owner.pets[1]).toBeInstanceOf(Cat);

    const mock = mockLogger(orm, ['query']);
    await orm.em.persistAndFlush(owner);
    expect(mock.mock.calls[0][0]).toMatch('begin');
    expect(mock.mock.calls[1][0]).toMatch('insert into `owner` (`name`, `pet_type`, `pet_name`, `pet_can_meow`, `pet2`, `pets`) values (?, ?, ?, ?, ?, ?)');
    expect(mock.mock.calls[2][0]).toMatch('commit');

    orm.em.assign(owner, {
      pet: { name: 'cat name' },
      pet2: { name: 'dog name' },
    });
    expect(() => orm.em.assign(owner, { pets: [{ name: '...' } ] })).toThrow('Cannot create entity Cat | Dog, class prototype is unknown');
    orm.em.assign(owner, {
      pets: [
        { name: 'cat in array', type: AnimalType.CAT },
        { name: 'dog in array', type: AnimalType.DOG },
        { name: 'cat in array 2', type: AnimalType.CAT },
      ],
    });
    await orm.em.persistAndFlush(owner);
    expect(mock.mock.calls[3][0]).toMatch('begin');
    expect(mock.mock.calls[4][0]).toMatch('update `owner` set `pet_name` = ?, `pet2` = ?, `pets` = ? where `id` = ?');
    expect(mock.mock.calls[5][0]).toMatch('commit');

    expect(wrap(owner).toObject()).toEqual({
      id: owner.id,
      name: 'o1',
      pet: {
        canMeow: true,
        name: 'cat name',
        type: 0,
      },
      pet2: {
        canBark: true,
        name: 'dog name',
        type: 1,
      },
      pets: [
        { canMeow: true, name: 'cat in array', type: 0 },
        { canBark: true, name: 'dog in array', type: 1 },
        { canMeow: true, name: 'cat in array 2', type: 0 },
      ],
    });
  });

});
