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

test('listing APIs paginate, filter, isolate users, and expose folder counts', async () => {
  const fixture = await startTestServer();
  try {
    const seeded = seedListingFixtures(fixture.dataDir, fixture.sessionSecret);
    const aliceCookie = await login(fixture.baseUrl, 'list-alice', 'password123');
    const bobCookie = await login(fixture.baseUrl, 'list-bob', 'password123');

    const defaultEvents = await getJson(fixture.baseUrl, '/api/events', aliceCookie);
    assert.equal(defaultEvents.status, 200);
    assert.equal(defaultEvents.body.total, 5);
    assert.equal(defaultEvents.body.page, 1);
    assert.equal(defaultEvents.body.pageSize, 30);
    assert.equal(defaultEvents.body.events.length, 5);

    const firstPage = await getJson(fixture.baseUrl, '/api/events?page=1&pageSize=2', aliceCookie);
    const secondPage = await getJson(fixture.baseUrl, '/api/events?page=2&pageSize=2', aliceCookie);
    assert.equal(firstPage.body.total, 5);
    assert.equal(firstPage.body.events.length, 2);
    assert.equal(secondPage.body.events.length, 2);
    assert.equal(new Set([...firstPage.body.events, ...secondPage.body.events].map((event) => event.id)).size, 4);

    assert.equal((await getJson(fixture.baseUrl, '/api/events?status=failed', aliceCookie)).body.total, 1);
    assert.equal(
      (await getJson(fixture.baseUrl, `/api/events?domainId=${seeded.aliceSecondaryDomainId}`, aliceCookie)).body.total,
      1
    );
    assert.equal(
      (await getJson(fixture.baseUrl, '/api/events?recipient=target%2Bfilter%40example.net', aliceCookie)).body.total,
      1
    );
    const byQueue = await getJson(fixture.baseUrl, '/api/events?q=QUEUE-ALICE-FAILED', aliceCookie);
    assert.equal(byQueue.body.total, 1);
    assert.equal(byQueue.body.events[0].status, 'failed');
    const byMessageId = await getJson(fixture.baseUrl, `/api/events?q=mh-${seeded.specialEventId}`, aliceCookie);
    assert.equal(byMessageId.body.total, 1);
    assert.equal(byMessageId.body.events[0].messageId, `mh-${seeded.specialEventId}`);
    const byNumericId = await getJson(fixture.baseUrl, `/api/events?q=${seeded.specialEventId}`, aliceCookie);
    assert.equal(byNumericId.body.total, 1);
    assert.equal(byNumericId.body.events[0].id, seeded.specialEventId);
    const bySubject = await getJson(fixture.baseUrl, '/api/events?q=Message%20identifier%20event', aliceCookie);
    assert.equal(bySubject.body.total, 1);
    assert.equal(bySubject.body.events[0].id, seeded.specialEventId);
    assert.equal((await getJson(fixture.baseUrl, '/api/events?from=2000-01-01', aliceCookie)).body.total, 5);
    assert.equal((await getJson(fixture.baseUrl, '/api/events?to=2000-01-01', aliceCookie)).body.total, 0);

    const bobEvents = await getJson(fixture.baseUrl, '/api/events?q=QUEUE-BOB-ONLY', bobCookie);
    assert.equal(bobEvents.body.total, 1);
    assert.equal((await getJson(fixture.baseUrl, '/api/events?q=QUEUE-BOB-ONLY', aliceCookie)).body.total, 0);

    const inboxPage = await getJson(
      fixture.baseUrl,
      `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&page=1&pageSize=1`,
      aliceCookie
    );
    assert.equal(inboxPage.status, 200);
    assert.equal(inboxPage.body.total, 2);
    assert.equal(inboxPage.body.messages.length, 1);
    assert.equal(inboxPage.body.page, 1);
    assert.equal(inboxPage.body.pageSize, 1);

    const unread = await getJson(
      fixture.baseUrl,
      `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&folder=INBOX&read=false`,
      aliceCookie
    );
    assert.equal(unread.body.total, 1);
    assert.equal(unread.body.messages[0].read, false);
    const sent = await getJson(
      fixture.baseUrl,
      `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&folder=Sent`,
      aliceCookie
    );
    assert.equal(sent.body.total, 1);
    assert.equal(sent.body.messages[0].folder, 'Sent');
    const custom = await getJson(
      fixture.baseUrl,
      `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&folder=Projects`,
      aliceCookie
    );
    assert.equal(custom.body.total, 1);
    assert.equal(custom.body.messages[0].folder, 'Projects');
    const byInboundMessageId = await getJson(
      fixture.baseUrl,
      '/api/inbound-messages?folder=Projects&q=inbound-project-special',
      aliceCookie
    );
    assert.equal(byInboundMessageId.body.total, 1);
    assert.equal(byInboundMessageId.body.messages[0].messageId, '<inbound-project-special@example.net>');

    const isolatedMessages = await getJson(
      fixture.baseUrl,
      `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}`,
      bobCookie
    );
    assert.equal(isolatedMessages.body.total, 0);

    const folders = await getJson(
      fixture.baseUrl,
      `/api/inbound-mailboxes/${seeded.aliceMailboxId}/folders`,
      aliceCookie
    );
    assert.equal(folders.status, 200);
    assert.deepEqual(folderSummary(folders.body.folders, 'INBOX'), {
      name: 'INBOX',
      specialUse: null,
      messageCount: 2,
      unreadCount: 1
    });
    assert.deepEqual(folderSummary(folders.body.folders, 'Sent'), {
      name: 'Sent',
      specialUse: '\\Sent',
      messageCount: 1,
      unreadCount: 0
    });
    assert.deepEqual(folderSummary(folders.body.folders, 'Projects'), {
      name: 'Projects',
      specialUse: null,
      messageCount: 1,
      unreadCount: 1
    });
    assert.ok(folders.body.folders.some((folder) => folder.name === 'Drafts' && folder.messageCount === 0));

    const isolatedFolders = await getJson(
      fixture.baseUrl,
      `/api/inbound-mailboxes/${seeded.aliceMailboxId}/folders`,
      bobCookie
    );
    assert.equal(isolatedFolders.status, 404);
    assert.deepEqual(isolatedFolders.body.folders, []);

    for (const pathName of [
      '/api/events?page=0',
      '/api/events?page=abc',
      '/api/events?pageSize=101',
      '/api/events?domainId=-1',
      '/api/events?status=sent%20OR%201%3D1',
      '/api/events?from=not-a-date',
      '/api/events?from=2026-07-02&to=2026-07-01',
      '/api/inbound-messages?page=0',
      '/api/inbound-messages?pageSize=101',
      '/api/inbound-messages?mailboxId=nope',
      '/api/inbound-messages?folder=',
      '/api/inbound-messages?read=1'
    ]) {
      const response = await getJson(fixture.baseUrl, pathName, aliceCookie);
      assert.equal(response.status, 400, pathName);
      assert.equal(typeof response.body.error, 'string');
    }
  } finally {
    fixture.child.kill('SIGTERM');
    await waitForExit(fixture.child, 1000);
  }
});

test('SMTP API responses keep passwords write-only while internal relay auth still works', async () => {
  const relayServer = await startFakeSmtpServer();
  const fixture = await startTestServer();
  try {
    const cookie = await login(fixture.baseUrl, 'admin', 'password123');
    const singularSave = await requestJson(fixture.baseUrl, '/api/smtp-credential', cookie, {
      method: 'PUT',
      body: { username: 'legacy-smtp', password: 'legacy-secret' }
    });
    assertSecretSummary(singularSave.body.credential);
    const singularGet = await getJson(fixture.baseUrl, '/api/smtp-credential', cookie);
    assertSecretSummary(singularGet.body.credential);

    const credentialCreate = await requestJson(fixture.baseUrl, '/api/smtp-credentials', cookie, {
      method: 'POST',
      body: { username: 'app-smtp', password: 'app-secret' }
    });
    assert.equal(credentialCreate.status, 201);
    assertSecretSummary(credentialCreate.body.credential);
    const credentialId = credentialCreate.body.credential.id;
    const credentialList = await getJson(fixture.baseUrl, '/api/smtp-credentials', cookie);
    credentialList.body.credentials.forEach(assertSecretSummary);
    assertSecretSummary((await getJson(fixture.baseUrl, `/api/smtp-credentials/${credentialId}`, cookie)).body.credential);
    const credentialPatch = await requestJson(fixture.baseUrl, `/api/smtp-credentials/${credentialId}`, cookie, {
      method: 'PATCH',
      body: { username: 'app-smtp-renamed' }
    });
    assertSecretSummary(credentialPatch.body.credential);

    const domain = await requestJson(fixture.baseUrl, '/api/domains', cookie, {
      method: 'POST',
      body: {
        domain: 'write-only-relay.example',
        selector: 'mh',
        senderHost: 'mail.write-only-relay.example',
        sendingIp: '127.0.0.1'
      }
    });
    assert.equal(domain.status, 201);
    const relayCreate = await requestJson(fixture.baseUrl, '/api/smtp-relays', cookie, {
      method: 'POST',
      body: {
        name: 'Write-only relay',
        host: '127.0.0.1',
        port: relayServer.port,
        secure: false,
        username: 'relay-user',
        password: 'relay-secret',
        helo: 'mail.write-only-relay.example',
        isDefault: true
      }
    });
    assert.equal(relayCreate.status, 201);
    assertSecretSummary(relayCreate.body.relay);
    const relayId = relayCreate.body.relay.id;
    (await getJson(fixture.baseUrl, '/api/smtp-relays', cookie)).body.relays.forEach(assertSecretSummary);
    assertSecretSummary((await getJson(fixture.baseUrl, `/api/smtp-relays/${relayId}`, cookie)).body.relay);
    const relayPatch = await requestJson(fixture.baseUrl, `/api/smtp-relays/${relayId}`, cookie, {
      method: 'PATCH',
      body: { name: 'Write-only relay renamed' }
    });
    assertSecretSummary(relayPatch.body.relay);

    const send = await requestJson(fixture.baseUrl, '/api/send', cookie, {
      method: 'POST',
      body: {
        from: 'noreply@write-only-relay.example',
        to: 'recipient@example.net',
        subject: 'write-only credential test',
        text: 'hello',
        smtpRelayId: relayId
      }
    });
    assert.equal(send.status, 202);
    const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN '));
    assert.ok(authCommand);
    assert.equal(
      Buffer.from(authCommand.slice('AUTH PLAIN '.length), 'base64').toString('utf8'),
      '\0relay-user\0relay-secret'
    );
  } finally {
    fixture.child.kill('SIGTERM');
    await waitForExit(fixture.child, 1000);
    await relayServer.close();
  }
});

test('authentication next paths preserve safe deep links and reject open redirects', async () => {
  const fixture = await startTestServer();
  try {
    const target = '/domains/42?tab=dns&q=pending';
    const anonymous = await fetch(`${fixture.baseUrl}${target}`, { redirect: 'manual' });
    assert.equal(anonymous.status, 302);
    const loginLocation = new URL(anonymous.headers.get('location'), fixture.baseUrl);
    assert.equal(loginLocation.pathname, '/login');
    assert.equal(loginLocation.searchParams.get('next'), target);

    const successfulLogin = await loginResponse(fixture.baseUrl, 'admin', 'password123', target);
    assert.equal(successfulLogin.status, 200);
    const loginBody = await successfulLogin.json();
    assert.equal(loginBody.redirectTo, target);
    const cookie = sessionCookieFrom(successfulLogin);
    assert.ok(cookie);

    const authenticatedLogin = await fetch(
      `${fixture.baseUrl}/login?next=${encodeURIComponent(target)}`,
      { headers: { Cookie: cookie }, redirect: 'manual' }
    );
    assert.equal(authenticatedLogin.status, 302);
    assert.equal(authenticatedLogin.headers.get('location'), target);

    const queryUrlTarget = '/activity?q=https%3A%2F%2Fexample.com%2Fmessage';
    const queryUrlLogin = await loginResponse(fixture.baseUrl, 'admin', 'password123', queryUrlTarget);
    assert.equal(queryUrlLogin.status, 200);
    assert.equal((await queryUrlLogin.json()).redirectTo, queryUrlTarget);

    for (const unsafe of [
      '//evil.example/path',
      'https://evil.example/path',
      '/\\evil.example/path',
      '/%2F%2Fevil.example/path',
      '/%5C%5Cevil.example/path',
      '/%0Aevil',
      '/api/events'
    ]) {
      const response = await loginResponse(fixture.baseUrl, 'admin', 'password123', unsafe);
      assert.equal(response.status, 200, unsafe);
      assert.equal((await response.json()).redirectTo, '/', unsafe);
    }

    const invalidAuthenticatedLogin = await fetch(
      `${fixture.baseUrl}/login?next=${encodeURIComponent('//evil.example/path')}`,
      { headers: { Cookie: cookie }, redirect: 'manual' }
    );
    assert.equal(invalidAuthenticatedLogin.headers.get('location'), '/');
  } finally {
    fixture.child.kill('SIGTERM');
    await waitForExit(fixture.child, 1000);
  }
});

function seedListingFixtures(dataDir, sessionSecret) {
  const script = `
    import {
      createDomain,
      createInboundMailbox,
      createInboundMessage,
      createUser,
      initDatabase,
      logSendEvent,
      markInboundMessageRead
    } from './src/db.js';

    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
    const alice = createUser({ username: 'list-alice', email: 'list-alice@example.com', password: 'password123', status: 'active' });
    const bob = createUser({ username: 'list-bob', email: 'list-bob@example.com', password: 'password123', status: 'active' });
    const domain = (userId, name) => createDomain(userId, {
      domain: name,
      selector: 'mh',
      verificationToken: 'token-' + name,
      dkimPublic: 'public-' + name,
      dkimPrivate: 'private-' + name,
      senderHost: 'mail.' + name,
      sendingIp: '127.0.0.1',
      spfExtra: '',
      dmarcPolicy: 'none',
      dmarcRua: ''
    });
    const alicePrimary = domain(alice.id, 'listing-alice.example');
    const aliceSecondary = domain(alice.id, 'listing-alice-secondary.example');
    const bobDomain = domain(bob.id, 'listing-bob.example');

    const events = [
      { domainId: alicePrimary.id, status: 'sent', recipient: 'one@example.net', subject: 'First event', queueId: 'QUEUE-ALICE-1' },
      { domainId: alicePrimary.id, status: 'failed', recipient: 'two@example.net', subject: 'Failed event', queueId: 'QUEUE-ALICE-FAILED' },
      { domainId: alicePrimary.id, status: 'bounced', recipient: 'target+filter@example.net', subject: 'Recipient filter event', queueId: 'QUEUE-ALICE-3' },
      { domainId: aliceSecondary.id, status: 'sent', recipient: 'four@example.net', subject: 'Secondary domain event', queueId: 'QUEUE-ALICE-4' },
      { domainId: alicePrimary.id, status: 'sent', recipient: 'five@example.net', subject: 'Message identifier event', queueId: 'QUEUE-ALICE-5' }
    ];
    let specialEventId = null;
    for (const event of events) {
      const eventId = Number(logSendEvent({
        userId: alice.id,
        domainId: event.domainId,
        sender: 'noreply@listing-alice.example',
        recipients: [event.recipient],
        subject: event.subject,
        status: event.status,
        detail: event.status + ' detail',
        queueId: event.queueId
      }));
      if (event.subject === 'Message identifier event') specialEventId = eventId;
    }
    logSendEvent({
      userId: bob.id,
      domainId: bobDomain.id,
      sender: 'noreply@listing-bob.example',
      recipients: ['bob@example.net'],
      subject: 'Bob only',
      status: 'sent',
      detail: 'bob detail',
      queueId: 'QUEUE-BOB-ONLY'
    });

    const aliceMailbox = createInboundMailbox(alice.id, { address: 'inbox@listing-alice.example', password: 'mailbox-pass-123' });
    const bobMailbox = createInboundMailbox(bob.id, { address: 'inbox@listing-bob.example', password: 'mailbox-pass-123' });
    const createMessage = (mailbox, values) => createInboundMessage(mailbox, {
      sender: values.sender || 'sender@example.net',
      recipients: [mailbox.address],
      subject: values.subject,
      messageId: values.messageId,
      folder: values.folder || 'INBOX',
      rawMessage: 'Subject: ' + values.subject + '\\r\\n\\r\\n' + values.subject,
      textBody: values.subject,
      receivedAt: values.receivedAt
    });
    createMessage(aliceMailbox, { subject: 'Inbox unread', messageId: '<inbound-unread@example.net>', receivedAt: '2026-07-14T10:00:00.000Z' });
    const inboxRead = createMessage(aliceMailbox, { subject: 'Inbox read', messageId: '<inbound-read@example.net>', receivedAt: '2026-07-14T11:00:00.000Z' });
    markInboundMessageRead(alice.id, inboxRead.id, true);
    const sent = createMessage(aliceMailbox, { subject: 'Sent message', messageId: '<inbound-sent@example.net>', folder: 'Sent', receivedAt: '2026-07-14T12:00:00.000Z' });
    markInboundMessageRead(alice.id, sent.id, true);
    createMessage(aliceMailbox, { subject: 'Project message', messageId: '<inbound-project-special@example.net>', folder: 'Projects', receivedAt: '2026-07-14T13:00:00.000Z' });
    createMessage(bobMailbox, { subject: 'Bob private', messageId: '<inbound-bob@example.net>', receivedAt: '2026-07-14T14:00:00.000Z' });

    console.log(JSON.stringify({
      aliceSecondaryDomainId: aliceSecondary.id,
      aliceMailboxId: aliceMailbox.id,
      bobMailboxId: bobMailbox.id,
      specialEventId
    }));
  `;
  const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
    cwd: process.cwd(),
    env: { ...process.env, DATA_DIR: dataDir, SESSION_SECRET: sessionSecret },
    encoding: 'utf8'
  });
  assert.equal(result.status, 0, result.stderr || result.stdout);
  return JSON.parse(result.stdout);
}

async function startTestServer() {
  const port = await freePort();
  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-listing-api-test-'));
  const sessionSecret = 'listing-api-session-secret';
  const child = spawn(process.execPath, ['src/server.js'], {
    cwd: process.cwd(),
    env: {
      ...process.env,
      PORT: String(port),
      DATA_DIR: dataDir,
      SESSION_SECRET: sessionSecret,
      ADMIN_USER: 'admin',
      ADMIN_EMAIL: 'admin@example.com',
      ADMIN_PASSWORD: 'password123',
      DNS_AUTO_CHECK_ENABLED: 'false',
      DELIVERY_TRACKING_ENABLED: 'false',
      WEBHOOK_WORKER_ENABLED: 'false',
      SUBMISSION_ENABLED: 'false',
      IMAP_ENABLED: 'false',
      POP3_ENABLED: 'false'
    },
    stdio: ['ignore', 'pipe', 'pipe']
  });
  await waitForOutput(child, 'MailHub listening');
  return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
}

async function login(baseUrl, username, password) {
  const response = await loginResponse(baseUrl, username, password);
  assert.equal(response.status, 200);
  const cookie = sessionCookieFrom(response);
  assert.ok(cookie);
  return cookie;
}

function loginResponse(baseUrl, username, password, next = '') {
  return fetch(`${baseUrl}/api/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password, next })
  });
}

function sessionCookieFrom(response) {
  return response.headers.get('set-cookie')?.split(';')[0] || '';
}

async function getJson(baseUrl, pathname, cookie) {
  const response = await fetch(`${baseUrl}${pathname}`, { headers: { Cookie: cookie }, redirect: 'manual' });
  return { status: response.status, body: await response.json() };
}

async function requestJson(baseUrl, pathname, cookie, { method, body }) {
  const response = await fetch(`${baseUrl}${pathname}`, {
    method,
    headers: { 'Content-Type': 'application/json', Cookie: cookie },
    body: JSON.stringify(body)
  });
  return { status: response.status, body: await response.json() };
}

function folderSummary(folders, name) {
  const folder = folders.find((entry) => entry.name === name);
  assert.ok(folder, `expected folder ${name}`);
  return folder;
}

function assertSecretSummary(value) {
  assert.ok(value);
  assert.equal(value.passwordSet, true);
  assert.equal('password' in value, false);
  assert.equal('passwordHash' in value, false);
  assert.equal('passwordSecret' in value, false);
  assert.equal('passwordRecoverable' in value, false);
}

function freePort() {
  return new Promise((resolve, reject) => {
    const server = net.createServer();
    server.listen(0, '127.0.0.1', () => {
      const { port } = server.address();
      server.close((error) => (error ? reject(error) : resolve(port)));
    });
    server.on('error', reject);
  });
}

function waitForOutput(child, text, timeoutMs = 8000) {
  return new Promise((resolve, reject) => {
    let buffer = '';
    const timer = setTimeout(() => reject(new Error(`Timed out waiting for: ${text}\n${buffer}`)), timeoutMs);
    const onData = (chunk) => {
      buffer += String(chunk);
      if (!buffer.includes(text)) return;
      clearTimeout(timer);
      child.stdout?.off('data', onData);
      child.stderr?.off('data', onData);
      resolve();
    };
    child.stdout?.on('data', onData);
    child.stderr?.on('data', onData);
  });
}

function waitForExit(child, timeoutMs) {
  return new Promise((resolve) => {
    if (child.exitCode != null) return resolve(true);
    const timer = setTimeout(() => resolve(false), timeoutMs);
    child.once('exit', () => {
      clearTimeout(timer);
      resolve(true);
    });
  });
}

function startFakeSmtpServer() {
  const commands = [];
  const server = net.createServer((socket) => {
    socket.setEncoding('utf8');
    socket.write('220 relay.test ESMTP ready\r\n');
    let buffer = '';
    let dataMode = false;
    socket.on('data', (chunk) => {
      buffer += chunk;
      let index;
      while ((index = buffer.indexOf('\n')) !== -1) {
        const line = buffer.slice(0, index).replace(/\r$/, '');
        buffer = buffer.slice(index + 1);
        if (dataMode) {
          if (line === '.') {
            dataMode = false;
            socket.write('250 2.0.0 queued as WRITEONLY123\r\n');
          }
          continue;
        }
        commands.push(line);
        if (line.startsWith('EHLO')) socket.write('250-relay.test\r\n250 AUTH PLAIN\r\n');
        else if (line.startsWith('AUTH PLAIN')) socket.write('235 2.7.0 authenticated\r\n');
        else if (line.startsWith('MAIL FROM')) socket.write('250 2.1.0 ok\r\n');
        else if (line.startsWith('RCPT TO')) socket.write('250 2.1.5 ok\r\n');
        else if (line === 'DATA') {
          dataMode = true;
          socket.write('354 end data\r\n');
        } else if (line === 'QUIT') {
          socket.write('221 bye\r\n');
          socket.end();
        }
      }
    });
  });
  return new Promise((resolve, reject) => {
    server.once('error', reject);
    server.listen(0, '127.0.0.1', () => {
      server.off('error', reject);
      resolve({
        port: server.address().port,
        commands,
        close: () => new Promise((closeResolve) => server.close(closeResolve))
      });
    });
  });
}
