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

import { assert } from 'chai';
import fsExtra from 'fs-extra';
import { v4 as generateUuid } from 'uuid';
import { readdirSync } from 'node:fs';
import { dirname } from 'node:path';

import { missingCaseError } from '../util/missingCaseError.std.ts';
import {
  getDownloadsPath,
  getAttachmentsPath,
} from '../../app/attachments.node.ts';

import { IMAGE_JPEG, LONG_MESSAGE } from '../types/MIME.std.ts';
import type { MessageAttributesType } from '../model-types.d.ts';
import type { AttachmentType } from '../types/Attachment.std.ts';
import {
  getAbsoluteAttachmentPath,
  getAbsoluteDownloadsPath,
  getAbsoluteDraftPath,
  maybeDeleteAttachmentFile,
} from '../util/migrations.preload.ts';
import { strictAssert } from '../util/assert.std.ts';
import {
  cleanupAllMessageAttachmentFiles,
  cleanupAttachmentFiles,
  cleanupMessages,
} from '../util/cleanup.preload.ts';
import { DataReader, DataWriter } from '../sql/Client.preload.ts';
import {
  testAttachmentLocalKey,
  testPlaintextHash,
} from '../test-helpers/attachments.node.ts';
import { generateAci } from '../test-helpers/serviceIdUtils.std.ts';

const { emptyDir, ensureFile } = fsExtra;

function getAbsolutePath(
  path: string,
  type: 'attachment' | 'download' | 'draft'
) {
  switch (type) {
    case 'attachment':
      return getAbsoluteAttachmentPath(path);
    case 'download':
      return getAbsoluteDownloadsPath(path);
    case 'draft':
      return getAbsoluteDraftPath(path);
    default:
      throw missingCaseError(type);
  }
}

async function writeFile(
  path: string,
  type: 'attachment' | 'download' | 'draft'
) {
  await ensureFile(getAbsolutePath(path, type));
}

async function writeFiles(
  num: number,
  type: 'attachment' | 'download' | 'draft'
) {
  for (let i = 0; i < num; i += 1) {
    // oxlint-disable-next-line no-await-in-loop
    await writeFile(`${type}${i}`, type);
  }
}

function listFiles(type: 'attachment' | 'download' | 'draft'): Array<string> {
  return readdirSync(dirname(getAbsolutePath('fakename', type)));
}

let attachmentIndex = 0;
let downloadIndex = 0;

describe('deleteMessageAttachments', () => {
  beforeEach(async () => {
    attachmentIndex = 0;
    downloadIndex = 0;
    await DataWriter.removeAll();
    window.ConversationController.reset();
    await window.ConversationController.load();
    await emptyDir(
      getAttachmentsPath(window.SignalContext.config.userDataPath)
    );
    await emptyDir(getDownloadsPath(window.SignalContext.config.userDataPath));
  });

  afterEach(async () => {
    await DataWriter.removeAll();
    window.ConversationController.reset();
    await emptyDir(
      getAttachmentsPath(window.SignalContext.config.userDataPath)
    );
    await emptyDir(getDownloadsPath(window.SignalContext.config.userDataPath));
  });

  function getAttachmentFilePath() {
    const path = `attachment${attachmentIndex}`;
    attachmentIndex += 1;
    return path;
  }
  function getDownloadFilePath() {
    const path = `download${downloadIndex}`;
    downloadIndex += 1;
    return path;
  }

  function composeAttachment(): AttachmentType {
    return {
      contentType: IMAGE_JPEG,
      size: 128,
      version: 2,
      path: getAttachmentFilePath(),
      localKey: testAttachmentLocalKey(),
      downloadPath: getDownloadFilePath(),
      plaintextHash: testPlaintextHash(),
      thumbnail: {
        contentType: IMAGE_JPEG,
        size: 128,
        version: 2,
        path: getAttachmentFilePath(),
        localKey: testAttachmentLocalKey(),
      },
      screenshot: {
        contentType: IMAGE_JPEG,
        size: 128,
        version: 2,
        path: getAttachmentFilePath(),
        localKey: testAttachmentLocalKey(),
      },
      thumbnailFromBackup: {
        contentType: IMAGE_JPEG,
        size: 128,
        version: 2,
        path: getAttachmentFilePath(),
        localKey: testAttachmentLocalKey(),
      },
    };
  }

  function composeBodyAttachment() {
    return {
      contentType: LONG_MESSAGE,
      size: 128,
      path: getAttachmentFilePath(),
      downloadPath: getDownloadFilePath(),
    };
  }

  function composeMessage(): MessageAttributesType {
    return {
      id: generateUuid(),
      type: 'outgoing',
      sent_at: Date.now(),
      timestamp: Date.now(),
      received_at: Date.now(),
      conversationId: generateUuid(),
    };
  }

  // Update these if more paths are added to composeMessageWithAllAttachments
  const NUM_ATTACHMENT_FILES_IN_MESSAGE = 42;
  const NUM_DOWNLOAD_FILES_IN_MESSAGE = 12;
  function composeMessageWithAllAttachments(): MessageAttributesType {
    const message: MessageAttributesType = {
      ...composeMessage(),
      bodyAttachment: composeBodyAttachment(),
      attachments: [composeAttachment(), composeAttachment()],
      contact: [
        {
          avatar: {
            isProfile: false,
            avatar: composeAttachment(),
          },
        },
      ],
      preview: [
        {
          url: 'url',
          image: composeAttachment(),
        },
      ],

      quote: {
        id: Date.now(),
        isViewOnce: false,
        referencedMessageNotFound: false,
        attachments: [
          {
            contentType: IMAGE_JPEG,
            thumbnail: composeAttachment(),
          },
        ],
      },
      sticker: {
        packId: 'packId',
        stickerId: 42,
        packKey: 'packKey',
        data: composeAttachment(),
      },
    };
    message.editHistory = [
      {
        timestamp: Date.now(),
        received_at: Date.now(),
        bodyAttachment: message.bodyAttachment,
        attachments: message.attachments,
        preview: message.preview,
        quote: message.quote,
      },
      {
        timestamp: Date.now(),
        received_at: Date.now(),
        bodyAttachment: composeBodyAttachment(),
        attachments: [composeAttachment(), composeAttachment()],
        preview: [
          {
            url: 'url',
            image: composeAttachment(),
          },
        ],
        quote: {
          id: Date.now(),
          isViewOnce: false,
          referencedMessageNotFound: false,
          attachments: [
            {
              contentType: IMAGE_JPEG,
              thumbnail: composeAttachment(),
            },
          ],
        },
      },
    ];
    return message;
  }

  describe('isSafeToDeleteAttachment', () => {
    beforeEach(async () => {
      await writeFiles(5, 'attachment');
    });

    it('is safe to delete if no references', async () => {
      assert.isTrue(await DataReader.isAttachmentSafeToDelete('attachment0'));
    });
    it('is not safe to delete if a message references it', async () => {
      const attachment1: AttachmentType = {
        size: 1,
        contentType: IMAGE_JPEG,
        path: 'attachment0',
        version: 2,
        thumbnail: { size: 1, contentType: IMAGE_JPEG, path: 'attachment1' },
        screenshot: { size: 1, contentType: IMAGE_JPEG, path: 'attachment2' },
        thumbnailFromBackup: {
          size: 1,
          contentType: IMAGE_JPEG,
          path: 'attachment3',
        },
      };

      await DataWriter.saveMessage(
        { ...composeMessage(), attachments: [attachment1] },
        {
          ourAci: generateAci(),
          forceSave: true,
          postSaveUpdates: () => Promise.resolve(),
        }
      );

      assert.isFalse(await DataReader.isAttachmentSafeToDelete('attachment0'));
      assert.isFalse(await DataReader.isAttachmentSafeToDelete('attachment1'));
      assert.isFalse(await DataReader.isAttachmentSafeToDelete('attachment2'));
      assert.isFalse(await DataReader.isAttachmentSafeToDelete('attachment3'));
      assert.isTrue(await DataReader.isAttachmentSafeToDelete('attachment4'));

      assert.deepStrictEqual(await maybeDeleteAttachmentFile('attachment0'), {
        wasDeleted: false,
      });
      assert.deepStrictEqual(await maybeDeleteAttachmentFile('attachment1'), {
        wasDeleted: false,
      });
      assert.deepStrictEqual(await maybeDeleteAttachmentFile('attachment2'), {
        wasDeleted: false,
      });
      assert.deepStrictEqual(await maybeDeleteAttachmentFile('attachment3'), {
        wasDeleted: false,
      });
      assert.deepStrictEqual(await maybeDeleteAttachmentFile('attachment4'), {
        wasDeleted: true,
      });
      assert.sameDeepMembers(listFiles('attachment'), [
        'attachment0',
        'attachment1',
        'attachment2',
        'attachment3',
      ]);
    });

    it('is not safe to delete if the file is protected, even if no references', async () => {
      await DataWriter._protectAttachmentPathFromDeletion({
        path: 'attachment0',
        messageId: 'messageId',
      });

      assert.isFalse(await DataReader.isAttachmentSafeToDelete('attachment0'));
      assert.isTrue(await DataReader.isAttachmentSafeToDelete('attachment1'));
      assert.isTrue(await DataReader.isAttachmentSafeToDelete('attachment2'));
      assert.isTrue(await DataReader.isAttachmentSafeToDelete('attachment3'));
      assert.isTrue(await DataReader.isAttachmentSafeToDelete('attachment4'));
    });

    it('properly counts attachment references', async () => {
      const attachment1 = {
        size: 1200,
        contentType: IMAGE_JPEG,
        path: 'attachment1',
        localKey: testAttachmentLocalKey(),
        plaintextHash: testPlaintextHash(),
        version: 2,
      } as const;

      const message1: MessageAttributesType = {
        id: generateUuid(),
        timestamp: Date.now(),
        sent_at: Date.now(),
        received_at: Date.now(),
        type: 'incoming',
        conversationId: 'convoId',
        attachments: [attachment1],
      };
      const message2 = { ...message1, id: generateUuid() };
      const message3 = { ...message1, id: generateUuid() };

      assert.isTrue(await DataReader.isAttachmentSafeToDelete('attachment1'));

      await DataWriter.saveMessage(message1, {
        forceSave: true,
        ourAci: generateAci(),
        postSaveUpdates: () => Promise.resolve(),
      });

      assert.isFalse(await DataReader.isAttachmentSafeToDelete('attachment1'));
      // Protect it twice
      await DataWriter.getAndProtectExistingAttachmentPath({
        plaintextHash: attachment1.plaintextHash,
        version: 2,
        contentType: IMAGE_JPEG,
        messageId: message2.id,
      });

      await DataWriter.getAndProtectExistingAttachmentPath({
        plaintextHash: attachment1.plaintextHash,
        version: 2,
        contentType: IMAGE_JPEG,
        messageId: message3.id,
      });

      // Delete the original message
      await DataWriter.removeMessageById(message1.id, {
        cleanupMessages,
      });
      assert.isFalse(await DataReader.isAttachmentSafeToDelete('attachment1'));

      // Save message2
      await DataWriter.saveMessage(message2, {
        forceSave: true,
        ourAci: generateAci(),
        postSaveUpdates: () => Promise.resolve(),
      });

      // Delete message2
      await DataWriter.removeMessageById(message2.id, {
        cleanupMessages,
      });

      assert.isFalse(await DataReader.isAttachmentSafeToDelete('attachment1'));

      // Save message3
      await DataWriter.saveMessage(message3, {
        forceSave: true,
        ourAci: generateAci(),
        postSaveUpdates: () => Promise.resolve(),
      });

      assert.isFalse(await DataReader.isAttachmentSafeToDelete('attachment1'));

      // Delete message3
      await DataWriter.removeMessageById(message3.id, {
        cleanupMessages,
      });
      assert.isTrue(await DataReader.isAttachmentSafeToDelete('attachment1'));
    });
  });

  describe('cleanupAllMessageAttachmentFiles', () => {
    it('deletes all referenced files, including those in editHistory', async () => {
      await writeFiles(NUM_ATTACHMENT_FILES_IN_MESSAGE + 3, 'attachment');
      await writeFiles(NUM_DOWNLOAD_FILES_IN_MESSAGE + 3, 'download');
      const message = composeMessageWithAllAttachments();

      await cleanupAllMessageAttachmentFiles(message);

      assert.strictEqual(attachmentIndex, NUM_ATTACHMENT_FILES_IN_MESSAGE);
      assert.strictEqual(downloadIndex, NUM_DOWNLOAD_FILES_IN_MESSAGE);

      assert.sameDeepMembers(listFiles('attachment'), [
        'attachment42',
        'attachment43',
        'attachment44',
      ]);

      assert.sameDeepMembers(listFiles('download'), [
        'download12',
        'download13',
        'download14',
      ]);
    });

    it('does not delete any attachment file if message is still saved, but does cleanup downloads', async () => {
      await writeFiles(NUM_ATTACHMENT_FILES_IN_MESSAGE, 'attachment');
      await writeFiles(NUM_DOWNLOAD_FILES_IN_MESSAGE, 'download');
      const message = composeMessageWithAllAttachments();
      assert.strictEqual(attachmentIndex, NUM_ATTACHMENT_FILES_IN_MESSAGE);
      assert.strictEqual(downloadIndex, NUM_DOWNLOAD_FILES_IN_MESSAGE);
      await DataWriter.saveMessage(message, {
        forceSave: true,
        ourAci: generateAci(),
        postSaveUpdates: () => Promise.resolve(),
      });

      await cleanupAllMessageAttachmentFiles(message);

      assert.strictEqual(
        listFiles('attachment').length,
        NUM_ATTACHMENT_FILES_IN_MESSAGE
      );
      assert.strictEqual(listFiles('download').length, 0);
    });

    it('does not delete an attachment file if referenced by another message', async () => {
      await writeFiles(NUM_ATTACHMENT_FILES_IN_MESSAGE, 'attachment');
      await writeFiles(NUM_DOWNLOAD_FILES_IN_MESSAGE, 'download');
      const message1 = composeMessageWithAllAttachments();
      const duplicatedAttachment: AttachmentType = message1.attachments?.[0];
      const message2: MessageAttributesType = {
        ...composeMessage(),
        attachments: [duplicatedAttachment],
      };
      assert.strictEqual(attachmentIndex, NUM_ATTACHMENT_FILES_IN_MESSAGE);
      assert.strictEqual(downloadIndex, NUM_DOWNLOAD_FILES_IN_MESSAGE);

      await DataWriter.saveMessage(message2, {
        forceSave: true,
        ourAci: generateAci(),
        postSaveUpdates: () => Promise.resolve(),
      });

      await cleanupAllMessageAttachmentFiles(message1);

      assert.sameDeepMembers(listFiles('attachment'), [
        duplicatedAttachment.path,
        duplicatedAttachment.thumbnail?.path,
        duplicatedAttachment.screenshot?.path,
        duplicatedAttachment.thumbnailFromBackup?.path,
      ]);
      assert.strictEqual(listFiles('download').length, 0);
    });

    it('does not delete an attachment path if protected', async () => {
      await writeFiles(NUM_ATTACHMENT_FILES_IN_MESSAGE, 'attachment');
      await writeFiles(NUM_DOWNLOAD_FILES_IN_MESSAGE, 'download');
      const message1 = composeMessageWithAllAttachments();
      const attachment1: AttachmentType = message1.attachments?.[0];

      assert.strictEqual(attachmentIndex, NUM_ATTACHMENT_FILES_IN_MESSAGE);
      assert.strictEqual(downloadIndex, NUM_DOWNLOAD_FILES_IN_MESSAGE);

      strictAssert(attachment1.plaintextHash, 'plaintextHash exists');
      strictAssert(attachment1.version, 'version exists');
      await DataWriter.saveMessage(message1, {
        forceSave: true,
        ourAci: generateAci(),
        postSaveUpdates: () => Promise.resolve(),
      });

      // protect existing attachment paths
      const existingAttachment =
        await DataWriter.getAndProtectExistingAttachmentPath({
          plaintextHash: attachment1.plaintextHash,
          version: attachment1.version,
          contentType: attachment1.contentType,
          messageId: 'newmessage',
        });

      assert.strictEqual(existingAttachment?.path, attachment1.path);
      assert.strictEqual(existingAttachment?.localKey, attachment1.localKey);

      // delete existing message (e.g. before the new message using the attachment has
      // been saved)
      await DataWriter.removeMessageById(message1.id, { cleanupMessages });

      await cleanupAllMessageAttachmentFiles(message1);

      assert.sameDeepMembers(listFiles('attachment'), [
        attachment1.path,
        attachment1.thumbnail?.path,
        attachment1.screenshot?.path,
      ]);
      assert.strictEqual(listFiles('download').length, 0);
    });
  });

  describe('cleanupAttachmentFiles', () => {
    beforeEach(async () => {
      await writeFiles(5, 'attachment');
      await writeFiles(5, 'download');
    });

    it('cleans up attachment files', async () => {
      const attachment: AttachmentType = {
        size: 1,
        contentType: IMAGE_JPEG,
        path: 'attachment0',
        version: 2,
        downloadPath: 'download0',
        thumbnail: { size: 1, contentType: IMAGE_JPEG, path: 'attachment1' },
        screenshot: { size: 1, contentType: IMAGE_JPEG, path: 'attachment2' },
        thumbnailFromBackup: {
          size: 1,
          contentType: IMAGE_JPEG,
          path: 'attachment3',
        },
      };
      await cleanupAttachmentFiles(attachment);
      assert.sameDeepMembers(listFiles('attachment'), ['attachment4']);
      assert.sameDeepMembers(listFiles('download'), [
        'download1',
        'download2',
        'download3',
        'download4',
      ]);
    });
    it('does not delete files if referenced', async () => {
      const attachment: AttachmentType = {
        size: 1,
        contentType: IMAGE_JPEG,
        path: 'attachment0',
        version: 2,
        downloadPath: 'download0',
        thumbnail: { size: 1, contentType: IMAGE_JPEG, path: 'attachment1' },
        screenshot: { size: 1, contentType: IMAGE_JPEG, path: 'attachment2' },
        thumbnailFromBackup: {
          size: 1,
          contentType: IMAGE_JPEG,
          path: 'attachment3',
        },
      };

      await DataWriter.saveMessage(
        { ...composeMessage(), attachments: [attachment] },
        {
          ourAci: generateAci(),
          forceSave: true,
          postSaveUpdates: () => Promise.resolve(),
        }
      );
      await cleanupAttachmentFiles(attachment);
      // Only downloadPath gets cleaned up
      assert.sameDeepMembers(listFiles('attachment'), [
        'attachment0',
        'attachment1',
        'attachment2',
        'attachment3',
        'attachment4',
      ]);
      assert.sameDeepMembers(listFiles('download'), [
        'download1',
        'download2',
        'download3',
        'download4',
      ]);
    });

    it('does not delete files if protected', async () => {
      const attachment: AttachmentType = {
        size: 1,
        contentType: IMAGE_JPEG,
        path: 'attachment0',
        version: 2,
        downloadPath: 'download0',
        thumbnail: { size: 1, contentType: IMAGE_JPEG, path: 'attachment1' },
        screenshot: { size: 1, contentType: IMAGE_JPEG, path: 'attachment2' },
        thumbnailFromBackup: {
          size: 1,
          contentType: IMAGE_JPEG,
          path: 'attachment3',
        },
      };

      await DataWriter._protectAttachmentPathFromDeletion({
        path: 'attachment0',
        messageId: 'messageId',
      });
      await cleanupAttachmentFiles(attachment);
      assert.sameDeepMembers(listFiles('attachment'), [
        'attachment0',
        'attachment4',
      ]);
    });
    it('does not delete copied quote thumbnails', async () => {
      const attachment: AttachmentType = {
        size: 1,
        contentType: IMAGE_JPEG,
        path: 'attachment0',
        version: 2,
        copied: true,
      };

      await cleanupAttachmentFiles(attachment);
      // not cleaned up
      assert.sameDeepMembers(listFiles('attachment'), [
        'attachment0',
        'attachment1',
        'attachment2',
        'attachment3',
        'attachment4',
      ]);

      // sanity check: if not copied, gets cleaned up
      await cleanupAttachmentFiles({ ...attachment, copied: false });
      assert.sameDeepMembers(listFiles('attachment'), [
        'attachment1',
        'attachment2',
        'attachment3',
        'attachment4',
      ]);
    });
  });
});
