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

import { assert } from 'chai';
import * as sinon from 'sinon';
import { EventEmitter } from 'node:events';
import { v4 as uuid } from 'uuid';

import { ReleaseNoteAndMegaphoneFetcher } from '../../services/releaseNoteAndMegaphoneFetcher.preload.ts';
import * as durations from '../../util/durations/index.std.ts';
import { saveNewMessageBatcher } from '../../util/messageBatcher.preload.ts';
import type { CIType } from '../../CI.preload.ts';
import type { ConversationModel } from '../../models/conversations.preload.ts';
import { itemStorage } from '../../textsecure/Storage.preload.ts';
import { DataReader, DataWriter } from '../../sql/Client.preload.ts';
import type { RemoteMegaphoneId } from '../../types/Megaphone.std.ts';
import { generateAci } from '../../test-helpers/serviceIdUtils.std.ts';

const { getAllMegaphones, hasMegaphone } = DataReader;

const waitUntil = (
  condition: () => boolean,
  timeoutMs = 5000
): Promise<void> => {
  return new Promise((resolve, reject) => {
    const startTime = Date.now();
    const intervalMs = 10;

    const intervalId = setInterval(() => {
      if (condition()) {
        clearInterval(intervalId);
        resolve();
      } else if (Date.now() - startTime > timeoutMs) {
        clearInterval(intervalId);
        reject(new Error('waitUntil timeout'));
      }
    }, intervalMs);
  });
};

const testMegaphone = {
  id: 'banana' as RemoteMegaphoneId,
  desktopMinVersion: '1.0.0',
  priority: 1,
  dontShowBeforeEpochMs: 0,
  dontShowAfterEpochMs: Date.now() + 9001,
  showForNumberOfDays: 7,
  primaryCtaId: null,
  secondaryCtaId: null,
  primaryCtaData: null,
  secondaryCtaData: null,
  conditionalId: null,
  title: 'a',
  body: 'b',
  primaryCtaText: null,
  secondaryCtaText: null,
  imagePath: 'megaphone0',
  localeFetched: 'en',
  shownAt: null,
  snoozedAt: null,
  snoozeCount: 0,
  isFinished: false,
} as const;

describe('ReleaseNoteAndMegaphoneFetcher', () => {
  const NEXT_FETCH_TIME_STORAGE_KEY = 'releaseNotesNextFetchTime';
  const PREVIOUS_MANIFEST_HASH_STORAGE_KEY = 'releaseNotesPreviousManifestHash';
  const VERSION_WATERMARK_STORAGE_KEY = 'releaseNotesVersionWatermark';
  const FETCH_INTERVAL = 3 * durations.DAY;

  type TestSetupOptions = {
    // Storage values
    storedVersionWatermark?: string;
    storedPreviousManifestHash?: string;
    storedNextFetchTime?: number;

    // Version configuration
    currentVersion?: string;
    noteVersion?: string;
    isNewVersion?: boolean;

    // Server responses
    isOnline?: boolean;
    manifestHash?: string;
    manifestAnnouncements?: Array<{
      uuid: string;
      desktopMinVersion: string;
      ctaId: string;
      link: string;
    }>;
    manifestMegaphones?: Array<{
      uuid: string;
      priority: number;
      desktopMinVersion: string;
      dontShowBeforeEpochSeconds: number;
      dontShowAfterEpochSeconds: number;
      showForNumberOfDays: number;
      conditionalId: string;
      primaryCtaId: string;
      secondaryCtaId: string;
      secondaryCtaData: object;
    }>;
    megaphone?: {
      uuid: string;
      title: string;
      body: string;
      image?: string;
      primaryCtaText?: string;
      secondaryCtaText?: string;
    };
    releaseNote?: {
      uuid: string;
      title: string;
      body: string;
      bodyRanges: Array<{ start: number; length: number; style: string }>;
    };

    // Conversation behavior
    conversationIsBlocked?: boolean;

    // Timing
    now?: number;
  };

  let sandbox: sinon.SinonSandbox;
  let originalSignalCI: CIType | undefined;
  let fakeMegaphoneUuid: string;

  async function setupTest(options: TestSetupOptions = {}) {
    sandbox = sinon.createSandbox();

    // Reset conversation controller for clean state
    window.ConversationController.reset();
    await window.ConversationController.load();

    const {
      storedVersionWatermark = '1.36.0',
      storedPreviousManifestHash,
      storedNextFetchTime,
      currentVersion = '1.36.0',
      noteVersion = '1.37.0',
      isNewVersion = false,
      isOnline = true,
      manifestHash = 'abc123',
      manifestAnnouncements,
      manifestMegaphones,
      megaphone,
      releaseNote,
      conversationIsBlocked = false,
      now = 1621500000000,
    } = options;

    const events = new EventEmitter();
    const fakeNoteUuid = uuid();
    fakeMegaphoneUuid = uuid();

    // Create fake conversation
    const fakeConversation = {
      isBlocked: sandbox.stub().returns(conversationIsBlocked),
      onNewMessage: sandbox.stub().resolves(),
      getServiceId: sandbox.stub().returns(generateAci()),
      set: sandbox.stub(),
      throttledUpdateUnread: sandbox.stub(),
      id: 'fake-signal-conversation-id',
    };

    // Stub global methods
    sandbox
      .stub(window.ConversationController, 'getOrCreateSignalConversation')
      .resolves(fakeConversation as unknown as ConversationModel);

    sandbox.stub(window.MessageCache, 'register').callsFake(message => message);

    // Save original values before modifying
    originalSignalCI = window.SignalCI;

    // Stub server methods
    const serverStubs = {
      isOnline: sandbox.stub().returns(isOnline),
      getMegaphone: sandbox.stub().resolves(
        megaphone || {
          uuid: fakeMegaphoneUuid,
          title: 'megaphone',
          body: 'cats',
          image: 'https://signal.org/axolotl.png',
          primaryCtaText: 'donate',
          secondaryCtaText: 'snooze',
        }
      ),
      getReleaseNotesManifestHash: sandbox.stub().resolves(manifestHash),
      getReleaseNotesManifest: sandbox.stub().resolves({
        announcements: manifestAnnouncements || [
          {
            uuid: fakeNoteUuid,
            desktopMinVersion: noteVersion,
            ctaId: 'test-cta',
            link: 'https://signal.org',
          },
        ],
        megaphones: manifestMegaphones || [
          {
            uuid: fakeMegaphoneUuid,
            priority: 100,
            desktopMinVersion: noteVersion,
            dontShowBeforeEpochSeconds: Math.floor(
              (Date.now() - 1 * durations.DAY) / 1000
            ),
            dontShowAfterEpochSeconds: Math.floor(
              (Date.now() + 30 * durations.DAY) / 1000
            ),
            showForNumberOfDays: 30,
            conditionalId: 'standard_donate',
            primaryCtaId: 'donate',
            secondaryCtaId: 'snooze',
            secondaryCtaData: { snoozeDurationDays: [5, 7, 100] },
          },
        ],
      }),
      getReleaseNoteHash: sandbox.stub().resolves('note-hash-1'),
      getReleaseNote: sandbox.stub().resolves(
        releaseNote || {
          uuid: fakeNoteUuid,
          title: 'New Release',
          body: 'This is the body text of the release note',
          bodyRanges: [{ start: 0, length: 4, style: 'bold' }],
        }
      ),
      getReleaseNoteImageAttachment: sandbox.stub().resolves({
        imageData: new Uint8Array([1, 2, 3]),
        contentType: 'image/png',
      }),
    };

    // Stub other globals
    sandbox.stub(window.SignalContext, 'getI18nLocale').returns('en-US');
    sandbox.stub(window, 'getVersion').returns(currentVersion);

    // Mock Whisper events
    const fakeWhisperEvents = new EventEmitter();
    sandbox.stub(window.Whisper, 'events').value(fakeWhisperEvents);

    // Mock saveNewMessageBatcher
    sandbox.stub(saveNewMessageBatcher, 'add').resolves();

    // Mock SignalCI
    window.SignalCI =
      window.SignalCI ||
      ({
        handleEvent: sandbox.stub(),
      } as unknown as CIType);

    // Helper to run fetcher and wait for completion
    const runFetcherAndWaitForCompletion = async () => {
      await ReleaseNoteAndMegaphoneFetcher.init(
        serverStubs,
        events,
        isNewVersion
      );

      // Wait for SignalCI.handleEvent to be called
      const signalCI = window.SignalCI as unknown as {
        handleEvent: sinon.SinonStub;
      };
      await waitUntil(() => signalCI.handleEvent.called, 1000);
    };

    // Storage setup helper
    const setupStorage = async () => {
      // Set up storage values
      await itemStorage.put('chromiumRegistrationDone', '');

      if (storedVersionWatermark !== undefined) {
        await itemStorage.put(
          VERSION_WATERMARK_STORAGE_KEY,
          storedVersionWatermark
        );
      } else {
        await itemStorage.remove(VERSION_WATERMARK_STORAGE_KEY);
      }

      if (storedPreviousManifestHash !== undefined) {
        await itemStorage.put(
          PREVIOUS_MANIFEST_HASH_STORAGE_KEY,
          storedPreviousManifestHash
        );
      } else {
        await itemStorage.remove(PREVIOUS_MANIFEST_HASH_STORAGE_KEY);
      }

      if (storedNextFetchTime !== undefined) {
        await itemStorage.put(NEXT_FETCH_TIME_STORAGE_KEY, storedNextFetchTime);
      } else {
        await itemStorage.remove(NEXT_FETCH_TIME_STORAGE_KEY);
      }
    };

    // Helper functions to get current storage values
    const getCurrentHash = () => {
      return itemStorage.get(PREVIOUS_MANIFEST_HASH_STORAGE_KEY);
    };

    const getCurrentWatermark = () => {
      return itemStorage.get(VERSION_WATERMARK_STORAGE_KEY);
    };

    return {
      // Core objects
      sandbox,
      events,
      fakeConversation,
      serverStubs,

      // Constants
      NEXT_FETCH_TIME_STORAGE_KEY,
      PREVIOUS_MANIFEST_HASH_STORAGE_KEY,
      VERSION_WATERMARK_STORAGE_KEY,
      FETCH_INTERVAL,

      // Test data
      fakeNoteUuid,
      now,

      // Helper functions
      runFetcherAndWaitForCompletion,
      setupStorage,
      getCurrentHash,
      getCurrentWatermark,
    };
  }

  beforeEach(async () => {
    await DataWriter.removeAll();
    await itemStorage.user.setAciAndDeviceId(generateAci(), 1);
    await itemStorage.user.setNumber('+14155550111');
  });

  afterEach(async () => {
    // Reset static state
    ReleaseNoteAndMegaphoneFetcher.initComplete = false;

    // Restore all stubs and timers
    sandbox.restore();
    sandbox.reset();

    // Restore original global values (even if they were undefined)
    // oxlint-disable-next-line typescript/no-non-null-assertion
    window.SignalCI = originalSignalCI!;

    // Reset storage state
    await itemStorage.fetch();

    // Reset conversation controller for next test
    window.ConversationController.reset();
  });

  describe('#run', () => {
    it('initializes version watermark if not set', async () => {
      const {
        setupStorage,
        runFetcherAndWaitForCompletion,
        getCurrentWatermark,
      } = await setupTest({
        storedVersionWatermark: undefined,
        isNewVersion: true,
      });

      await setupStorage();
      await runFetcherAndWaitForCompletion();

      const storedWatermark = getCurrentWatermark();

      assert.strictEqual(storedWatermark, '1.36.0');
    });

    it('fetches manifest when hash changes', async () => {
      const {
        setupStorage,
        runFetcherAndWaitForCompletion,
        serverStubs,
        getCurrentHash,
      } = await setupTest({
        storedPreviousManifestHash: 'old-hash',
        manifestHash: 'new-hash-123',
        isNewVersion: true,
      });

      await setupStorage();
      await runFetcherAndWaitForCompletion();

      sinon.assert.calledOnce(serverStubs.getReleaseNotesManifest);

      assert.strictEqual(getCurrentHash(), 'new-hash-123');
    });

    // TODO(DESKTOP-9092): test setup requires isNewVersion=true, but
    // that flag forces a manifest fetch.
    // oxlint-disable-next-line signal-desktop/no-disabled-tests
    it.skip('does not fetch when hash is the same', async () => {
      const {
        setupStorage,
        runFetcherAndWaitForCompletion,
        serverStubs,
        getCurrentHash,
      } = await setupTest({
        storedPreviousManifestHash: 'hash',
        manifestHash: 'hash',
        isNewVersion: true,
      });

      await setupStorage();
      await runFetcherAndWaitForCompletion();

      sinon.assert.notCalled(serverStubs.getReleaseNotesManifest);

      assert.strictEqual(getCurrentHash(), 'hash');
    });

    it('forces a manifest fetch for a new version', async () => {
      const {
        setupStorage,
        runFetcherAndWaitForCompletion,
        serverStubs,
        getCurrentHash,
      } = await setupTest({
        storedPreviousManifestHash: 'hash',
        manifestHash: 'hash',
        isNewVersion: true,
      });

      await setupStorage();
      await runFetcherAndWaitForCompletion();

      sinon.assert.calledOnce(serverStubs.getReleaseNotesManifest);

      assert.strictEqual(getCurrentHash(), 'hash');
    });

    it('processes release notes when valid notes are found and updates watermark', async () => {
      const {
        setupStorage,
        runFetcherAndWaitForCompletion,
        serverStubs,
        getCurrentWatermark,
      } = await setupTest({
        storedPreviousManifestHash: 'old-hash',
        manifestHash: 'new-hash-123',
        currentVersion: 'v1.37.0',
        noteVersion: 'v1.37.0',
        storedVersionWatermark: 'v1.36.0',
        isNewVersion: true,
      });

      await setupStorage();
      await runFetcherAndWaitForCompletion();

      sinon.assert.calledOnce(serverStubs.getReleaseNotesManifest);
      sinon.assert.calledOnce(serverStubs.getReleaseNote);
      sinon.assert.called(window.MessageCache.register as sinon.SinonStub);

      assert.strictEqual(getCurrentWatermark(), 'v1.37.0');
    });

    it('processes megaphones', async () => {
      const myMegaphone = {
        uuid: uuid(),
        priority: 100,
        desktopMinVersion: 'v1.37.0',
        dontShowBeforeEpochSeconds: Math.floor(
          (Date.now() - 1 * durations.DAY) / 1000
        ),
        dontShowAfterEpochSeconds: Math.floor(
          (Date.now() + 30 * durations.DAY) / 1000
        ),
        showForNumberOfDays: 30,
        conditionalId: 'standard_donate',
        primaryCtaId: 'donate',
        secondaryCtaId: 'snooze',
        secondaryCtaData: { snoozeDurationDays: [5, 7, 100] },
      };
      const manifestMegaphones = [myMegaphone];

      const { setupStorage, runFetcherAndWaitForCompletion, serverStubs } =
        await setupTest({
          storedPreviousManifestHash: 'old-hash',
          manifestHash: 'new-hash-123',
          currentVersion: 'v1.37.0',
          noteVersion: 'v1.37.0',
          storedVersionWatermark: 'v1.36.0',
          isNewVersion: true,
          manifestMegaphones,
          megaphone: {
            uuid: myMegaphone.uuid,
            title: 'megaphone',
            body: 'cats',
            image: 'https://signal.org/axolotl.png',
            primaryCtaText: 'donate',
            secondaryCtaText: 'snooze',
          },
        });

      await setupStorage();
      await runFetcherAndWaitForCompletion();

      sinon.assert.calledOnce(serverStubs.getMegaphone);
      sinon.assert.calledOnce(serverStubs.getReleaseNoteImageAttachment);

      const dbMegaphones = await getAllMegaphones();
      const dbMegaphone = dbMegaphones[0];
      assert.strictEqual(dbMegaphones.length, 1);
      assert.strictEqual(dbMegaphone?.id, myMegaphone.uuid);
      assert.strictEqual(
        dbMegaphone?.dontShowBeforeEpochMs,
        myMegaphone.dontShowBeforeEpochSeconds * 1000
      );
      assert.strictEqual(
        dbMegaphone?.dontShowAfterEpochMs,
        myMegaphone.dontShowAfterEpochSeconds * 1000
      );
    });

    it('only processes megaphones with matching countries value', async () => {
      const baseMegaphone = {
        priority: 100,
        desktopMinVersion: 'v1.37.0',
        dontShowBeforeEpochSeconds: Math.floor(
          (Date.now() - 1 * durations.DAY) / 1000
        ),
        dontShowAfterEpochSeconds: Math.floor(
          (Date.now() + 30 * durations.DAY) / 1000
        ),
        showForNumberOfDays: 30,
        conditionalId: 'standard_donate',
        primaryCtaId: 'donate',
        secondaryCtaId: 'snooze',
        secondaryCtaData: { snoozeDurationDays: [5, 7, 100] },
      };
      const megaphoneForMyCountry = {
        ...baseMegaphone,
        uuid: uuid(),
        countries: '1:1000000',
      };
      const megaphoneForDifferentCountry = {
        ...baseMegaphone,
        uuid: uuid(),
        countries: '20:1000000,212:1000000,213:1000000,216:1000000',
      };
      const megaphoneForMyCountryBucketZeroPPM = {
        ...baseMegaphone,
        uuid: uuid(),
        countries: '1:0',
      };
      const wildcardMegaphone = {
        ...baseMegaphone,
        uuid: uuid(),
        countries: '*:1000000',
      };
      const manifestMegaphones = [
        megaphoneForDifferentCountry,
        megaphoneForMyCountry,
        megaphoneForMyCountryBucketZeroPPM,
        wildcardMegaphone,
      ];

      const { setupStorage, runFetcherAndWaitForCompletion, serverStubs } =
        await setupTest({
          storedPreviousManifestHash: 'old-hash',
          manifestHash: 'new-hash-123',
          currentVersion: 'v1.37.0',
          noteVersion: 'v1.37.0',
          storedVersionWatermark: 'v1.36.0',
          isNewVersion: true,
          manifestMegaphones,
          megaphone: {
            uuid: megaphoneForMyCountry.uuid,
            title: 'megaphone',
            body: 'cats',
            image: 'https://signal.org/axolotl.png',
            primaryCtaText: 'donate',
            secondaryCtaText: 'snooze',
          },
        });

      await setupStorage();
      await runFetcherAndWaitForCompletion();

      sinon.assert.calledTwice(serverStubs.getMegaphone);

      const dbMegaphones = await getAllMegaphones();
      assert.strictEqual(dbMegaphones.length, 2);
      assert.strictEqual(dbMegaphones[0]?.id, megaphoneForMyCountry.uuid);
      assert.strictEqual(dbMegaphones[1]?.id, wildcardMegaphone.uuid);
    });
  });

  it('deletes saved megaphones which were removed from the manifest', async () => {
    await DataWriter.createMegaphone(testMegaphone);
    const isOldMegaphonePresentBeforeRun = await hasMegaphone(testMegaphone.id);
    assert.isTrue(isOldMegaphonePresentBeforeRun, 'saved during setup');

    const { setupStorage, runFetcherAndWaitForCompletion } = await setupTest({
      storedPreviousManifestHash: 'old-hash',
      manifestHash: 'new-hash-123',
      currentVersion: 'v1.37.0',
      noteVersion: 'v1.37.0',
      storedVersionWatermark: 'v1.36.0',
      isNewVersion: true,
    });

    await setupStorage();
    await runFetcherAndWaitForCompletion();

    const isOldMegaphonePresentAfterRun = await hasMegaphone(testMegaphone.id);
    assert.isFalse(isOldMegaphonePresentAfterRun, 'deleted after run');

    const dbMegaphones = await getAllMegaphones();
    assert.strictEqual(dbMegaphones.length, 1);
    const dbMegaphone = dbMegaphones[0];
    assert.strictEqual(dbMegaphone?.id, fakeMegaphoneUuid);
  });

  it('deletes saved megaphones if the manifest has empty megaphones', async () => {
    await DataWriter.createMegaphone(testMegaphone);
    const isOldMegaphonePresentBeforeRun = await hasMegaphone(testMegaphone.id);
    assert.isTrue(isOldMegaphonePresentBeforeRun, 'saved during setup');

    const { setupStorage, runFetcherAndWaitForCompletion } = await setupTest({
      storedPreviousManifestHash: 'old-hash',
      manifestHash: 'new-hash-123',
      currentVersion: 'v1.37.0',
      noteVersion: 'v1.37.0',
      storedVersionWatermark: 'v1.36.0',
      isNewVersion: true,
      manifestMegaphones: [],
    });

    await setupStorage();
    await runFetcherAndWaitForCompletion();

    const dbMegaphones = await getAllMegaphones();
    assert.strictEqual(dbMegaphones.length, 0);
  });
});
