import assert from 'node:assert/strict';
import {
  mkdirSync,
  mkdtempSync,
  renameSync,
  utimesSync,
  writeFileSync
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { test } from 'node:test';

import {
  createVestaMessageSourceKey,
  decodeModifiedUtf7,
  importVestaSnapshot,
  iterateVestaMaildirMessages,
  parseVestaConfigLine,
  readVestaSnapshotMetadata
} from '../src/vesta-maildir-import.js';

test('Vesta metadata reader safely parses domains, accounts and legacy hashes', async () => {
  const fixture = createSnapshotFixture();
  const snapshot = await readVestaSnapshotMetadata({ root: fixture.root });

  assert.equal(snapshot.domains.length, 2);
  assert.deepEqual(snapshot.domains[0], {
    source: 'vesta',
    vestaUser: 'admin',
    name: 'example.com',
    domain: 'example.com',
    catchAll: 'alice@example.com',
    catchAllAddress: 'alice@example.com',
    suspended: false,
    status: 'active',
    config: {
      DOMAIN: 'example.com',
      CATCHALL: 'alice',
      SUSPENDED: 'no'
    },
    sourceFile: path.join(fixture.root, 'usr/local/vesta/data/users/admin/mail.conf')
  });
  assert.equal(snapshot.mailboxes.length, 1);
  assert.equal(snapshot.domains[1].catchAll, '');
  assert.match(snapshot.mailboxes[0].legacyPasswordHash, /^\{MD5\}\$1\$/);
  assert.equal(snapshot.mailboxes[0].passwordScheme, 'md5-crypt');
  assert.deepEqual(snapshot.mailboxes[0].aliases, ['sales@example.com', 'help@example.com']);
  assert.deepEqual(snapshot.mailboxes[0].forwardTo, ['archive@elsewhere.test']);
  assert.equal(snapshot.mailboxes[0].keepForwarded, true);
  assert.equal(snapshot.mailboxes[0].quotaMb, null);
  assert.equal(snapshot.mailboxes[0].maildirPath, fixture.maildir);
  assert.ok(snapshot.mailboxes[0].folders.includes('Empty'));
});

test('Maildir reader preserves folders, raw bytes, mtime, flags and keywords', async () => {
  const fixture = createSnapshotFixture();
  const snapshot = await readVestaSnapshotMetadata({ root: fixture.root });
  const messages = [];
  for await (const message of iterateVestaMaildirMessages(snapshot.mailboxes[0])) messages.push(message);

  assert.equal(messages.length, 3);
  const inbox = messages.find((message) => message.folder === 'INBOX');
  assert.deepEqual(inbox.flags, ['\\Answered', '\\Seen', 'X']);
  assert.deepEqual(inbox.keywords, ['$Label1', 'b']);
  assert.equal(inbox.maildirFlags, 'RSXab');
  assert.equal(inbox.modifiedAt, '2024-01-02T03:04:05.000Z');
  assert.ok(inbox.rawMessageBytes.equals(fixture.inboxBytes));

  const sent = messages.find((message) => message.folder === 'Sent');
  assert.equal(sent.maildirFlags, 'S');
  const chinese = messages.find((message) => message.folder === '中文');
  assert.ok(chinese);
  assert.match(chinese.sourcePath, /^\.&Ti1lhw-\/new\//);

  const originalKey = inbox.sourceKey;
  const renamed = path.join(fixture.maildir, 'new', '1700000000.M1P1.host:2,S');
  renameSync(fixture.inboxPath, renamed);
  const renamedBytesHash = inbox.contentSha256;
  assert.equal(originalKey, createVestaMessageSourceKey({
    address: 'alice@example.com',
    folder: 'INBOX',
    fileName: path.basename(renamed),
    contentSha256: renamedBytesHash
  }));
});

test('importVestaSnapshot is dry-run safe, MIME-aware and idempotent by sourceKey', async () => {
  const fixture = createSnapshotFixture();
  const stored = new Map();
  const folders = [];
  const adapter = {
    async ensureDomain(domain) {
      return { id: domain.name };
    },
    async ensureMailbox(mailbox) {
      return { id: mailbox.address };
    },
    async ensureFolder(_mailbox, folder) {
      folders.push(folder);
    },
    async hasMessage(sourceKey) {
      return stored.has(sourceKey);
    },
    async createMessage(message) {
      stored.set(message.sourceKey, message);
    }
  };
  const checkpoints = [];

  const first = await importVestaSnapshot({
    root: fixture.root,
    adapter,
    onCheckpoint: (sourceKey) => checkpoints.push(sourceKey)
  });
  assert.equal(first.messages, 3);
  assert.equal(first.importedMessages, 3);
  assert.equal(first.skippedMessages, 0);
  assert.equal(first.plannedMessages, 0);
  assert.equal(stored.size, 3);
  assert.ok(folders.includes('Empty'));
  assert.equal(checkpoints.at(-1), first.lastSourceKey);
  assert.ok([...stored.values()].every((message) => Buffer.isBuffer(message.rawMessageBytes)));

  const inbox = [...stored.values()].find((message) => message.folder === 'INBOX');
  assert.equal(inbox.read, true);
  assert.equal(inbox.receivedAt, '2024-01-02T03:04:05.000Z');
  const chinese = [...stored.values()].find((message) => message.folder === '中文');
  assert.equal(chinese.messageId, '');
  assert.equal(chinese.subject, 'No message id');
  const sent = [...stored.values()].find((message) => message.folder === 'Sent');
  assert.deepEqual(sent.recipients, ['bob@example.net']);

  const second = await importVestaSnapshot({ root: fixture.root, adapter });
  assert.equal(second.importedMessages, 0);
  assert.equal(second.skippedMessages, 3);
  assert.equal(stored.size, 3);

  const neverWrite = new Proxy({}, {
    get() {
      return () => {
        throw new Error('dry-run called adapter');
      };
    }
  });
  const dryRun = await importVestaSnapshot({ root: fixture.root, adapter: neverWrite, dryRun: true });
  assert.equal(dryRun.importedMessages, 0);
  assert.equal(dryRun.plannedMessages, 3);
  assert.equal(dryRun.messages, 3);

  const orderedMessages = [];
  const snapshot = await readVestaSnapshotMetadata({ root: fixture.root });
  for await (const message of iterateVestaMaildirMessages(snapshot.mailboxes[0])) orderedMessages.push(message);
  const resumed = await importVestaSnapshot({
    root: fixture.root,
    dryRun: true,
    resumeAfterSourceKey: orderedMessages[0].sourceKey
  });
  assert.equal(resumed.resumeSkippedMessages, 1);
  assert.equal(resumed.plannedMessages, 2);
  await assert.rejects(
    importVestaSnapshot({ root: fixture.root, dryRun: true, resumeAfterSourceKey: 'missing' }),
    /断点不存在/
  );
});

test('malformed MIME is imported as raw mail and concurrent deduplication is counted as skipped', async () => {
  const fixture = createSnapshotFixture();
  const rawMessages = [];
  const result = await importVestaSnapshot({
    root: fixture.root,
    parseMessage: async () => {
      throw new Error('malformed MIME');
    },
    adapter: {
      async ensureDomain(domain) {
        return domain;
      },
      async ensureMailbox(mailbox) {
        return mailbox;
      },
      async hasMessage() {
        return false;
      },
      async createMessage(message) {
        rawMessages.push(message.rawMessageBytes);
        return { created: false };
      }
    }
  });

  assert.equal(result.importedMessages, 0);
  assert.equal(result.skippedMessages, 3);
  assert.equal(result.warningCount, result.warnings.length + 3);
  assert.equal(rawMessages.length, 3);
  assert.ok(rawMessages.every(Buffer.isBuffer));
});

test('config and Modified UTF-7 parsing do not execute shell syntax', () => {
  assert.deepEqual(
    parseVestaConfigLine("ACCOUNT='alice' FWD='archive@example.net' SUSPENDED='no'"),
    { ACCOUNT: 'alice', FWD: 'archive@example.net', SUSPENDED: 'no' }
  );
  assert.equal(decodeModifiedUtf7('&Ti1lhw-'), '中文');
  assert.equal(decodeModifiedUtf7('R&-D &- QA'), 'R&D & QA');
});

function createSnapshotFixture() {
  const root = mkdtempSync(path.join(tmpdir(), 'mailhub-vesta-snapshot-'));
  const userData = path.join(root, 'usr/local/vesta/data/users/admin');
  const domainConfig = path.join(userData, 'mail');
  const confMail = path.join(root, 'home/admin/conf/mail/example.com');
  const maildir = path.join(root, 'home/admin/mail/example.com/alice');
  for (const directory of [
    domainConfig,
    confMail,
    path.join(maildir, 'cur'),
    path.join(maildir, 'new'),
    path.join(maildir, 'tmp'),
    path.join(maildir, '.Sent/cur'),
    path.join(maildir, '.Sent/new'),
    path.join(maildir, '.Empty/cur'),
    path.join(maildir, '.Empty/new'),
    path.join(maildir, '.&Ti1lhw-/cur'),
    path.join(maildir, '.&Ti1lhw-/new')
  ]) mkdirSync(directory, { recursive: true });

  writeFileSync(
    path.join(userData, 'mail.conf'),
    "DOMAIN='example.com' CATCHALL='alice' SUSPENDED='no'\n" +
    "DOMAIN='nocatch.example' CATCHALL='no' SUSPENDED='no'\n"
  );
  const legacyHash = '{MD5}$1$salt1234$abcdefghijklmnopqrstuv';
  writeFileSync(
    path.join(domainConfig, 'example.com.conf'),
    `ACCOUNT='alice' ALIAS='sales,help' AUTOREPLY='no' FWD='archive@elsewhere.test' FWD_ONLY='no' MD5='${legacyHash}' QUOTA='unlimited' U_DISK='0' SUSPENDED='no' TIME='03:04:05' DATE='2024-01-02'\n`
  );
  writeFileSync(
    path.join(confMail, 'passwd'),
    `alice:${legacyHash}:admin:mail::/home/admin:0:userdb_quota_rule=*:storage=0M\n`
  );
  writeFileSync(path.join(maildir, 'dovecot-keywords'), '0 $Label1\n');

  const inboxBytes = Buffer.concat([
    Buffer.from([
      'From: Bob <bob@example.net>',
      'To: alice@example.com',
      'Subject: Inbox message',
      'Message-ID: <inbox@example.net>',
      'Content-Type: text/plain; charset=iso-8859-1',
      '',
      'caf'
    ].join('\r\n'), 'ascii'),
    Buffer.from([0xe9, 0x0d, 0x0a])
  ]);
  const inboxPath = path.join(maildir, 'cur', '1700000000.M1P1.host:2,RSXab');
  writeFileSync(inboxPath, inboxBytes);
  const timestamp = new Date('2024-01-02T03:04:05.000Z');
  utimesSync(inboxPath, timestamp, timestamp);

  writeFileSync(path.join(maildir, '.Sent/cur', '1700000001.M2P1.host:2,S'), [
    'From: alice@example.com',
    'To: bob@example.net',
    'Subject: Sent message',
    'Message-ID: <sent@example.com>',
    '',
    'Sent body'
  ].join('\r\n'));
  writeFileSync(path.join(maildir, '.&Ti1lhw-/new', '1700000002.M3P1.host'), [
    'From: sender@example.net',
    'To: alice@example.com',
    'Subject: No message id',
    '',
    'Custom folder body'
  ].join('\r\n'));

  return { root, maildir, inboxBytes, inboxPath };
}
