import assert from 'node:assert/strict';
import { mkdtempSync } from 'node:fs';
import net from 'node:net';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { test } from 'node:test';

import { AuthenticationRateLimiter } from '../src/auth-rate-limit.js';
import { createDomain, createInboundMailbox, createUser, initDatabase } from '../src/db.js';
import { startMailboxAccessServers } from '../src/mail-access.js';
import { startSubmissionServer } from '../src/submission.js';

test('IMAP, POP3 and SMTP share generic authentication throttling by IP and account', async () => {
  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-auth-rate-limit-')), 'auth-rate-limit-secret');
  const user = createUser({
    username: 'auth-rate-limit-user',
    email: 'auth-rate-limit-user@example.com',
    password: 'account-password'
  });
  createDomain(user.id, {
    domain: 'auth-rate-limit.example',
    selector: 'mh',
    verificationToken: 'verify',
    dkimPublic: 'public',
    dkimPrivate: 'private',
    senderHost: 'mail.auth-rate-limit.example',
    sendingIp: '192.0.2.45',
    spfExtra: '',
    dmarcPolicy: 'none',
    dmarcRua: ''
  });
  const mailbox = createInboundMailbox(user.id, {
    address: 'admin@auth-rate-limit.example',
    password: 'correct-password'
  });
  const authRateLimiter = new AuthenticationRateLimiter({
    combinationLimit: 3,
    accountLimit: 10,
    ipLimit: 20
  });
  const [imapServer, pop3Server] = startMailboxAccessServers({
    hostname: 'mail.auth-rate-limit.example',
    imapEnabled: true,
    imapListeners: [{ port: 0, protocol: 'imap' }],
    pop3Enabled: true,
    pop3Listeners: [{ port: 0, protocol: 'pop3' }],
    allowInsecureAuth: true,
    authRateLimiter
  });
  const [smtpServer] = startSubmissionServer({
    enabled: true,
    listeners: [{ port: 0, protocol: 'smtp' }],
    hostname: 'mail.auth-rate-limit.example',
    allowInsecureAuth: true,
    inboundEnabled: true,
    authRateLimiter
  });
  const servers = [imapServer, pop3Server, smtpServer];
  await Promise.all(servers.map(waitForListening));

  const clients = [];
  try {
    const imap = await connectClient(imapServer.address().port);
    clients.push(imap);
    await imap.readUntil(/\* OK .* IMAP ready\r\n/);
    assert.match(
      await imap.command(`A1 LOGIN "${mailbox.address}" "wrong-imap"`, /A1 NO/),
      /^A1 NO Authentication failed\r\n$/
    );

    const pop3 = await connectClient(pop3Server.address().port);
    clients.push(pop3);
    await pop3.readUntil(/\+OK .* POP3 ready\r\n/);
    assert.match(await pop3.command(`USER ${mailbox.address}`, /\+OK|\-ERR/), /^\+OK User accepted\r\n$/);
    assert.match(await pop3.command('PASS wrong-pop3', /\+OK|\-ERR/), /^\-ERR Authentication failed\r\n$/);

    const smtp = await connectClient(smtpServer.address().port);
    clients.push(smtp);
    await smtp.readUntil(/^220 .* ready\r\n/m);
    await smtp.command('EHLO client.example', /250 HELP\r\n/);
    const wrongAuth = Buffer.from(`\u0000${mailbox.address}\u0000wrong-smtp`).toString('base64');
    assert.match(
      await smtp.command(`AUTH PLAIN ${wrongAuth}`, /535 /),
      /^535 Authentication failed\r\n$/
    );

    const blocked = await connectClient(smtpServer.address().port);
    clients.push(blocked);
    await blocked.readUntil(/^220 .* ready\r\n/m);
    await blocked.command('EHLO client.example', /250 HELP\r\n/);
    const correctAuth = Buffer.from(`\u0000${mailbox.address}\u0000correct-password`).toString('base64');
    assert.match(
      await blocked.command(`AUTH PLAIN ${correctAuth}`, /535 /),
      /^535 Authentication failed\r\n$/
    );
  } finally {
    for (const client of clients) client.close();
    await Promise.all(servers.map(closeServer));
  }
});

function connectClient(port) {
  return new Promise((resolve, reject) => {
    const socket = net.createConnection({ host: '127.0.0.1', port });
    socket.setTimeout(5_000);
    let buffer = '';
    const waiters = [];

    socket.on('data', (chunk) => {
      buffer += chunk.toString('utf8');
      for (const waiter of [...waiters]) {
        if (!waiter.pattern.test(buffer)) continue;
        waiters.splice(waiters.indexOf(waiter), 1);
        const output = buffer;
        buffer = '';
        clearTimeout(waiter.timer);
        waiter.resolve(output);
      }
    });
    socket.once('connect', () => resolve({
      command(command, pattern) {
        socket.write(`${command}\r\n`);
        return this.readUntil(pattern);
      },
      readUntil(pattern) {
        if (pattern.test(buffer)) {
          const output = buffer;
          buffer = '';
          return Promise.resolve(output);
        }
        return new Promise((waitResolve, waitReject) => {
          const waiter = { pattern, resolve: waitResolve, timer: null };
          waiter.timer = setTimeout(() => {
            waiters.splice(waiters.indexOf(waiter), 1);
            waitReject(new Error(`Timed out waiting for ${pattern}; buffered response: ${buffer}`));
          }, 5_000);
          waiters.push(waiter);
        });
      },
      close() {
        socket.destroy();
      }
    }));
    socket.once('error', reject);
    socket.once('timeout', () => reject(new Error('Mail protocol client timed out')));
  });
}

function waitForListening(server) {
  if (server.listening) return Promise.resolve();
  return new Promise((resolve) => server.once('listening', resolve));
}

function closeServer(server) {
  return new Promise((resolve, reject) => {
    server.close((error) => error ? reject(error) : resolve());
  });
}
