// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

import { assert } from 'chai';

import { strictAssert } from '../util/assert.std.ts';
import { DataWriter } from '../sql/Client.preload.ts';

import type { ConversationModel } from '../models/conversations.preload.ts';
import type {
  AciString,
  PniString,
  ServiceIdString,
} from '../types/ServiceId.std.ts';
import type { SafeCombineConversationsParams } from '../ConversationController.preload.ts';
import { signalProtocolStore } from '../SignalProtocolStore.preload.ts';
import {
  generateAci,
  generatePni,
} from '../test-helpers/serviceIdUtils.std.ts';

const ACI_1 = generateAci();
const ACI_2 = generateAci();
const E164_1 = '+14155550111';
const E164_2 = '+14155550112';
const PNI_1 = generatePni();
const PNI_2 = generatePni();
const reason = 'test';

type ParamsType = {
  serviceId?: ServiceIdString;
  aci?: AciString;
  e164?: string;
  pni?: PniString;
};

describe('ConversationController', () => {
  describe('maybeMergeContacts', () => {
    let mergeOldAndNew: (
      options: SafeCombineConversationsParams
    ) => Promise<void>;

    beforeEach(async () => {
      await DataWriter._removeAllConversations();

      window.ConversationController.reset();
      await window.ConversationController.load();
      await signalProtocolStore.hydrateCaches();

      mergeOldAndNew = () => {
        throw new Error('mergeOldAndNew: Should not be called!');
      };
    });

    it('throws when provided no data', () => {
      assert.throws(() => {
        window.ConversationController.maybeMergeContacts({
          mergeOldAndNew,
          reason,
        });
      }, 'Need to provide at least one');
    });

    async function create(
      name: string,
      { serviceId: maybeServiceId, aci, e164, pni }: ParamsType
    ): Promise<ConversationModel> {
      const identifier = aci || maybeServiceId || e164 || pni;
      const serviceId = aci || maybeServiceId || pni;

      strictAssert(identifier, 'create needs aci, e164, pni, or serviceId');

      const conversation =
        await window.ConversationController.getOrCreateAndWait(
          identifier,
          'private',
          { serviceId, e164, pni }
        );
      expectLookups(conversation, name, { serviceId, aci, e164, pni });

      return conversation;
    }

    function expectLookups(
      conversation: ConversationModel | undefined,
      name: string,
      { serviceId, aci, e164, pni }: ParamsType
    ) {
      assert.exists(conversation, `${name} conversation exists`);

      // Verify that this conversation hasn't been deleted
      assert.strictEqual(
        window.ConversationController.get(conversation?.id)?.id,
        conversation?.id,
        `${name} vs. lookup by id`
      );

      if (serviceId) {
        assert.strictEqual(
          window.ConversationController.get(serviceId)?.id,
          conversation?.id,
          `${name} vs. lookup by serviceId`
        );
      }
      if (aci) {
        assert.strictEqual(
          window.ConversationController.get(aci)?.id,
          conversation?.id,
          `${name} vs. lookup by aci`
        );
      }
      if (e164) {
        assert.strictEqual(
          window.ConversationController.get(e164)?.id,
          conversation?.id,
          `${name} vs. lookup by e164`
        );
      }
      if (pni) {
        assert.strictEqual(
          window.ConversationController.get(pni)?.id,
          conversation?.id,
          `${name} vs. lookup by pni`
        );
      }
    }

    function expectPropsAndLookups(
      conversation: ConversationModel | undefined,
      name: string,
      { serviceId, aci, e164, pni }: ParamsType
    ) {
      assert.exists(conversation, `${name} conversation exists`);
      assert.strictEqual(
        conversation?.getServiceId(),
        aci || serviceId,
        `${name} serviceId matches`
      );
      assert.strictEqual(
        conversation?.get('e164'),
        e164,
        `${name} e164 matches`
      );
      assert.strictEqual(conversation?.getPni(), pni, `${name} pni matches`);

      expectLookups(conversation, name, { serviceId, e164, pni });
    }

    function expectDeleted(conversation: ConversationModel, name: string) {
      assert.isUndefined(
        window.ConversationController.get(conversation.id),
        `${name} has been deleted`
      );
    }

    describe('non-destructive updates', () => {
      it('creates a new conversation with just ACI if no matches', () => {
        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            reason,
          });

        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
        });

        const { conversation: second } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            reason,
          });

        expectPropsAndLookups(second, 'second', {
          aci: ACI_1,
        });

        assert.strictEqual(result?.id, second?.id, 'result and second match');
      });
      it('creates a new conversation with just e164 if no matches', () => {
        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            e164: E164_1,
            reason,
          });

        expectPropsAndLookups(result, 'result', {
          e164: E164_1,
        });

        const { conversation: second } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            e164: E164_1,
            reason,
          });

        expectPropsAndLookups(second, 'second', {
          e164: E164_1,
        });

        assert.strictEqual(result?.id, second?.id, 'result and second match');
      });
      it('creates a new conversation with e164+PNI if no matches', () => {
        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });

        expectPropsAndLookups(result, 'result', {
          serviceId: PNI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        const { conversation: second } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });

        expectPropsAndLookups(second, 'second', {
          serviceId: PNI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        assert.strictEqual(result?.id, second?.id, 'result and second match');
      });
      it('creates a new conversation with all data if no matches', () => {
        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });

        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        const { conversation: second } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });

        expectPropsAndLookups(second, 'second', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        assert.strictEqual(result?.id, second?.id, 'result and second match');
      });

      it('fetches all-data conversation with ACI-only query', async () => {
        const initial = await create('initial', {
          aci: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            reason,
          });

        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        assert.strictEqual(result?.id, initial?.id, 'result and initial match');
      });

      it('fetches all-data conversation with e164+PNI query', async () => {
        const initial = await create('initial', {
          aci: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });

        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        assert.strictEqual(result?.id, initial?.id, 'result and initial match');
      });

      it('adds ACI to conversation with e164+PNI', async () => {
        const initial = await create('initial', {
          e164: E164_1,
          pni: PNI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        assert.strictEqual(initial?.id, result?.id, 'result and initial match');
      });
      it('adds ACI (via ACI+PNI) to conversation with e164+PNI', async () => {
        const initial = await create('initial', {
          serviceId: PNI_1,
          e164: E164_1,
        });

        expectPropsAndLookups(initial, 'initial', {
          serviceId: PNI_1,
          e164: E164_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        assert.strictEqual(initial?.id, result?.id, 'result and initial match');
      });

      it('adds e164+PNI to conversation with just ACI', async () => {
        const initial = await create('initial', {
          serviceId: ACI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        assert.strictEqual(result?.id, initial?.id, 'result and initial match');
      });
      it('adds e164 to conversation with ACI+PNI', async () => {
        const initial = await create('initial', {
          aci: ACI_1,
          pni: PNI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        assert.strictEqual(result?.id, initial?.id, 'result and initial match');
      });
      it('adds PNI to conversation with ACI+e164', async () => {
        const initial = await create('initial', {
          aci: ACI_1,
          e164: E164_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        assert.strictEqual(initial?.id, result?.id, 'result and initial match');
      });
      it('adds PNI to conversation with just e164', async () => {
        const initial = await create('initial', {
          e164: E164_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: PNI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        assert.strictEqual(initial?.id, result?.id, 'result and initial match');
      });
      it('adds PNI+ACI to conversation with just e164', async () => {
        const initial = await create('initial', {
          e164: E164_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          aci: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        assert.strictEqual(initial?.id, result?.id, 'result and initial match');
      });
      it('adds ACI+e164 to conversation with just PNI', async () => {
        const initial = await create('initial', {
          pni: PNI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          aci: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        assert.strictEqual(initial?.id, result?.id, 'result and initial match');
      });

      it('promotes PNI used as generic serviceId to be in the PNI field as well', async () => {
        const initial = await create('initial', {
          serviceId: PNI_1,
          e164: E164_1,
        });
        expectPropsAndLookups(initial, 'initial', {
          serviceId: PNI_1,
          e164: E164_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: PNI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        assert.strictEqual(initial?.id, result?.id, 'result and initial match');
      });
    });

    describe('with destructive updates', () => {
      it('replaces e164+PNI in conversation with matching ACI', async () => {
        const initial = await create('initial', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            e164: E164_2,
            pni: PNI_2,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_2,
          pni: PNI_2,
        });

        assert.isUndefined(
          window.ConversationController.get(E164_1),
          'old e164 no longer found'
        );
        assert.isUndefined(
          window.ConversationController.get(PNI_1),
          'old pni no longer found'
        );

        assert.strictEqual(result?.id, initial?.id, 'result and initial match');
      });

      it('replaces PNI in conversation with e164+PNI', async () => {
        const initial = await create('initial', {
          pni: PNI_1,
          e164: E164_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            pni: PNI_2,
            e164: E164_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: PNI_2,
          e164: E164_1,
          pni: PNI_2,
        });

        assert.isUndefined(
          window.ConversationController.get(PNI_1),
          'old pni no longer found'
        );

        assert.strictEqual(result?.id, initial?.id, 'result and initial match');
      });
      it('adds PNI to conversation with e164+ACI', async () => {
        const initial = await create('initial', {
          aci: ACI_1,
          e164: E164_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            pni: PNI_1,
            e164: E164_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        assert.strictEqual(result?.id, initial?.id, 'result and initial match');
      });
      it('replaces PNI in conversation with all data', async () => {
        const initial = await create('initial', {
          aci: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            e164: E164_1,
            pni: PNI_2,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_2,
        });

        assert.isUndefined(
          window.ConversationController.get(PNI_1),
          'old pni no longer found'
        );

        assert.strictEqual(result?.id, initial?.id, 'result and initial match');
      });

      it('removes e164+PNI from previous conversation with an ACI, adds all data to new conversation', async () => {
        const initial = await create('initial', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_2,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_2,
          e164: E164_1,
          pni: PNI_1,
        });

        expectPropsAndLookups(initial, 'initial', { serviceId: ACI_1 });

        assert.notStrictEqual(
          initial?.id,
          result?.id,
          'result and initial should not match'
        );
      });
      it('removes e164+PNI from previous conversation with an ACI, adds to ACI match', async () => {
        const initial = await create('initial', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });
        const aciOnly = await create('aciOnly', {
          serviceId: ACI_2,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_2,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(aciOnly, 'aciOnly', {
          serviceId: ACI_2,
          e164: E164_1,
          pni: PNI_1,
        });

        expectPropsAndLookups(initial, 'initial', { serviceId: ACI_1 });

        assert.strictEqual(
          aciOnly?.id,
          result?.id,
          'result and aciOnly should match'
        );
      });

      it('removes PNI from previous conversation, adds it to e164-only match', async () => {
        const withE164 = await create('withE164', {
          e164: E164_1,
        });
        const withPNI = await create('withPNI', {
          e164: E164_2,
          pni: PNI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: PNI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        expectPropsAndLookups(withPNI, 'withPNI', { e164: E164_2 });

        assert.strictEqual(
          withE164?.id,
          result?.id,
          'result and initial should match'
        );
      });
      it('removes PNI from previous conversation, adds it new e164+PNI conversation', async () => {
        const initial = await create('initial', {
          e164: E164_1,
          pni: PNI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            e164: E164_2,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: PNI_1,
          e164: E164_2,
          pni: PNI_1,
        });

        expectPropsAndLookups(initial, 'initial', { e164: E164_1 });

        assert.notStrictEqual(
          initial?.id,
          result?.id,
          'result and initial should not match'
        );
      });
      it('deletes PNI-only previous conversation, adds it to e164 match', async () => {
        mergeOldAndNew = ({ obsolete }) => {
          window.ConversationController.dangerouslyRemoveById(obsolete.id);
          return Promise.resolve();
        };

        const withE164 = await create('withE164', {
          e164: E164_1,
        });
        const withPNI = await create('withPNI', {
          pni: PNI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: PNI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        expectDeleted(withPNI, 'withPNI');

        assert.strictEqual(
          withE164?.id,
          result?.id,
          'result and initial should match'
        );
      });
      it('deletes previous conversation with PNI as serviceId only, adds it to e164 match', async () => {
        mergeOldAndNew = ({ obsolete }) => {
          window.ConversationController.dangerouslyRemoveById(obsolete.id);
          return Promise.resolve();
        };

        const withE164 = await create('withE164', {
          e164: E164_1,
        });
        const withPNI = await create('withPNI', {
          serviceId: PNI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: PNI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        expectDeleted(withPNI, 'withPNI');

        assert.strictEqual(
          withE164?.id,
          result?.id,
          'result and initial should match'
        );
      });
      it('deletes e164+PNI previous conversation, adds data to ACI match', async () => {
        mergeOldAndNew = ({ obsolete }) => {
          window.ConversationController.dangerouslyRemoveById(obsolete.id);
          return Promise.resolve();
        };

        const withE164 = await create('withE164', {
          e164: E164_1,
          pni: PNI_1,
        });
        const withACI = await create('withPNI', {
          aci: ACI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        expectDeleted(withE164, 'withE164');

        assert.strictEqual(
          withACI?.id,
          result?.id,
          'result and initial should match'
        );
      });

      it('handles three matching conversations: ACI-only, with E164, and with PNI', async () => {
        const withACI = await create('withACI', {
          aci: ACI_1,
        });
        const withE164 = await create('withE164', {
          aci: ACI_2,
          e164: E164_1,
        });
        const withPNI = await create('withPNI', {
          pni: PNI_1,
          e164: E164_2,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });
        expectPropsAndLookups(withE164, 'withE164', { aci: ACI_2 });
        expectPropsAndLookups(withPNI, 'withPNI', { e164: E164_2 });

        assert.strictEqual(result?.id, withACI?.id, 'result and withACI match');
      });
      it('handles three matching conversations: ACI-only, E164-only (deleted), and with PNI', async () => {
        mergeOldAndNew = ({ obsolete }) => {
          window.ConversationController.dangerouslyRemoveById(obsolete.id);
          return Promise.resolve();
        };

        const withACI = await create('withACI', {
          aci: ACI_1,
        });
        const withE164 = await create('withE164', {
          e164: E164_1,
        });
        const withPNI = await create('withPNI', {
          pni: PNI_1,
          e164: E164_2,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });
        expectPropsAndLookups(withPNI, 'withPNI', { e164: E164_2 });

        expectDeleted(withE164, 'withE164');

        assert.strictEqual(result?.id, withACI?.id, 'result and withACI match');
      });
      it('merges three matching conversations: ACI-only, E164-only (deleted), PNI-only (deleted)', async () => {
        mergeOldAndNew = ({ obsolete }) => {
          window.ConversationController.dangerouslyRemoveById(obsolete.id);
          return Promise.resolve();
        };

        const withACI = await create('withACI', {
          aci: ACI_1,
        });
        const withE164 = await create('withE164', {
          e164: E164_1,
        });
        const withPNI = await create('withPNI', {
          pni: PNI_1,
        });

        const { conversation: result } =
          window.ConversationController.maybeMergeContacts({
            mergeOldAndNew,
            aci: ACI_1,
            e164: E164_1,
            pni: PNI_1,
            reason,
          });
        expectPropsAndLookups(result, 'result', {
          serviceId: ACI_1,
          e164: E164_1,
          pni: PNI_1,
        });

        expectDeleted(withPNI, 'withPNI');
        expectDeleted(withE164, 'withE164');

        assert.strictEqual(result?.id, withACI?.id, 'result and withACI match');
      });
    });
  });
});
