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

test('mailbox grants constrain admin, session, token, and send API access', async (t) => {
  const smtp = await startFakeSmtpServer();
  const readerSmtp = await startFakeSmtpServer();
  const fixture = await startTestServer(smtp.port);

  try {
    const seeded = seedMailboxAccessFixtures(fixture.dataDir, fixture.sessionSecret, readerSmtp.port);
    const adminCookie = await login(fixture.baseUrl, 'admin', 'password123');
    const ownerCookie = await login(fixture.baseUrl, 'access-owner', 'password123');
    const readerCookie = await login(fixture.baseUrl, 'access-reader', 'password123');
    const viewerCookie = await login(fixture.baseUrl, 'access-viewer', 'password123');

    await t.test('only administrators can list and replace mailbox grants', async () => {
      const ordinaryList = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
        cookie: readerCookie
      });
      assert.equal(ordinaryList.status, 403);

      const ordinaryReplace = await requestJson(
        fixture.baseUrl,
        `/api/admin/inbound-mailboxes/${seeded.sharedMailboxId}/access`,
        {
          method: 'PUT',
          cookie: readerCookie,
          body: { grants: [] }
        }
      );
      assert.equal(ordinaryReplace.status, 403);

      const malformed = await fetch(
        `${fixture.baseUrl}/api/admin/inbound-mailboxes/${seeded.sharedMailboxId}/access`,
        {
          method: 'PUT',
          headers: {
            'Content-Type': 'application/json',
            Cookie: adminCookie
          },
          body: '{"grants":'
        }
      );
      assert.equal(malformed.status, 400);
      assert.equal(typeof (await malformed.json()).error, 'string');

      const initial = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
        cookie: adminCookie
      });
      assert.equal(initial.status, 200);
      const initialShared = findAdminMailbox(initial.body.mailboxes, seeded.sharedMailboxId);
      assert.equal(initialShared.owner.id, seeded.ownerUserId);
      assert.deepEqual(initialShared.grants, []);

      const replaced = await replaceGrants(
        fixture.baseUrl,
        adminCookie,
        seeded.sharedMailboxId,
        [
          { userId: seeded.readerUserId, permissions: ['receive', 'send'] },
          { userId: seeded.viewerUserId, permissions: ['view'] }
        ]
      );
      assert.equal(replaced.mailbox.id, seeded.sharedMailboxId);
      assert.equal(replaced.owner.id, seeded.ownerUserId);
      assert.deepEqual(grantPermissions(replaced, seeded.readerUserId), {
        view: true,
        receive: true,
        send: true
      });
      assert.deepEqual(grantPermissions(replaced, seeded.viewerUserId), {
        view: true,
        receive: false,
        send: false
      });

      const listed = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
        cookie: adminCookie
      });
      const listedShared = findAdminMailbox(listed.body.mailboxes, seeded.sharedMailboxId);
      assert.deepEqual(grantPermissions(listedShared, seeded.readerUserId), {
        view: true,
        receive: true,
        send: true
      });
    });

    await t.test('owned, effective, and all mailbox scopes stay distinct', async () => {
      assert.deepEqual(
        await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=owned', ownerCookie),
        ['other@access-owner.example', 'shared@access-owner.example']
      );
      assert.deepEqual(
        await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=owned', readerCookie),
        ['own@access-reader.example']
      );
      assert.deepEqual(
        await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=effective', readerCookie),
        ['own@access-reader.example', 'shared@access-owner.example']
      );
      assert.deepEqual(
        await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=effective', viewerCookie),
        ['shared@access-owner.example']
      );

      const ordinaryAll = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=all', {
        cookie: readerCookie
      });
      assert.equal(ordinaryAll.status, 403);

      const adminAll = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=all', {
        cookie: adminCookie
      });
      assert.equal(adminAll.status, 200);
      assert.deepEqual(
        adminAll.body.mailboxes.map((mailbox) => mailbox.address).sort(),
        [
          'other@access-owner.example',
          'own@access-reader.example',
          'shared@access-owner.example'
        ]
      );

      const invalid = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=unknown', {
        cookie: adminCookie
      });
      assert.equal(invalid.status, 400);
    });

    await t.test('receive grants allow message reads while view-only grants do not', async () => {
      const list = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}&q=Shared%20grant`,
        { cookie: readerCookie }
      );
      assert.equal(list.status, 200);
      assert.equal(list.body.total, 1);
      assert.deepEqual(list.body.messages.map((message) => message.id), [seeded.sharedMessageId]);

      const detail = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages/${seeded.sharedMessageId}`,
        { cookie: readerCookie }
      );
      assert.equal(detail.status, 200);
      assert.equal(detail.body.message.subject, 'Shared grant message');

      const marked = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages/${seeded.sharedMessageId}`,
        {
          method: 'PATCH',
          cookie: readerCookie,
          body: { read: true }
        }
      );
      assert.equal(marked.status, 200);
      assert.equal(marked.body.message.read, true);

      const viewerList = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
        { cookie: viewerCookie }
      );
      assert.equal(viewerList.status, 200);
      assert.equal(viewerList.body.total, 0);

      const viewerDetail = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages/${seeded.sharedMessageId}`,
        { cookie: viewerCookie }
      );
      assert.equal(viewerDetail.status, 404);

      const viewerMark = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages/${seeded.sharedMessageId}`,
        {
          method: 'PATCH',
          cookie: viewerCookie,
          body: { read: false }
        }
      );
      assert.equal(viewerMark.status, 404);
    });

    await t.test('Bearer owner scope cannot expand into grants and selected scope is revoked immediately', async () => {
      const ownerToken = await createToken(fixture.baseUrl, readerCookie, {
        name: 'grant owner scope',
        scopes: ['mailboxes:read', 'messages:read'],
        mailboxAccess: 'owner'
      });
      const ownerMessages = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', {
        bearer: ownerToken.token
      });
      assert.equal(ownerMessages.status, 200);
      assert.deepEqual(ownerMessages.body.messages.map((message) => message.id), [seeded.readerMessageId]);
      const ownerMailboxes = await requestJson(fixture.baseUrl, '/api/mailboxes', {
        bearer: ownerToken.token
      });
      assert.deepEqual(ownerMailboxes.body.mailboxes.map((mailbox) => mailbox.id), [seeded.readerMailboxId]);

      const selectedToken = await createToken(fixture.baseUrl, readerCookie, {
        name: 'grant selected scope',
        scopes: ['mailboxes:read', 'messages:read'],
        mailboxAccess: 'selected',
        mailboxIds: [seeded.sharedMailboxId]
      });
      const selectedMessages = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
        { bearer: selectedToken.token }
      );
      assert.equal(selectedMessages.status, 200);
      assert.deepEqual(selectedMessages.body.messages.map((message) => message.id), [seeded.sharedMessageId]);
      const selectedDetail = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages/${seeded.sharedMessageId}`,
        { bearer: selectedToken.token }
      );
      assert.equal(selectedDetail.status, 200);

      await replaceGrants(
        fixture.baseUrl,
        adminCookie,
        seeded.sharedMailboxId,
        [{ userId: seeded.viewerUserId, permissions: ['view'] }]
      );

      const afterRevoke = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
        { bearer: selectedToken.token }
      );
      assert.equal(afterRevoke.status, 200);
      assert.equal(afterRevoke.body.total, 0);
      const detailAfterRevoke = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages/${seeded.sharedMessageId}`,
        { bearer: selectedToken.token }
      );
      assert.equal(detailAfterRevoke.status, 404);
    });

    await t.test('Bearer send scope is intersected with token mailbox selection and live send grants', async () => {
      await replaceGrants(
        fixture.baseUrl,
        adminCookie,
        seeded.sharedMailboxId,
        [{ userId: seeded.readerUserId, permissions: ['send'] }]
      );
      const ownerToken = await createToken(fixture.baseUrl, readerCookie, {
        name: 'send owner scope',
        scopes: ['send'],
        mailboxAccess: 'owner'
      });
      const selectedSharedToken = await createToken(fixture.baseUrl, readerCookie, {
        name: 'send selected shared mailbox',
        scopes: ['send'],
        mailboxAccess: 'selected',
        mailboxIds: [seeded.sharedMailboxId]
      });
      const selectedOwnToken = await createToken(fixture.baseUrl, readerCookie, {
        name: 'send selected owned mailbox',
        scopes: ['send'],
        mailboxAccess: 'selected',
        mailboxIds: [seeded.readerMailboxId]
      });
      const mailFromCount = smtpMailFromCommands(smtp).length;

      await assertSendDenied(fixture.baseUrl, {
        bearer: ownerToken.token,
        from: 'shared@access-owner.example'
      });
      await assertSendDenied(fixture.baseUrl, {
        bearer: selectedOwnToken.token,
        from: 'shared@access-owner.example'
      });

      const selectedSend = await sendApiMessage(fixture.baseUrl, {
        bearer: selectedSharedToken.token,
        from: 'shared@access-owner.example',
        subject: 'Selected token mailbox grant send'
      });
      assert.equal(selectedSend.status, 202, JSON.stringify(selectedSend.body));
      assert.equal(selectedSend.body.queued, true);
      assert.deepEqual(
        smtpMailFromCommands(smtp).slice(mailFromCount),
        ['MAIL FROM:<shared@access-owner.example>']
      );

      await replaceGrants(fixture.baseUrl, adminCookie, seeded.sharedMailboxId, []);
      await assertSendDenied(fixture.baseUrl, {
        bearer: selectedSharedToken.token,
        from: 'shared@access-owner.example'
      });
      assert.equal(smtpMailFromCommands(smtp).length, mailFromCount + 1);
    });

    await t.test('send grants authorize only the exact mailbox address and aliases', async () => {
      await replaceGrants(
        fixture.baseUrl,
        adminCookie,
        seeded.sharedMailboxId,
        [
          { userId: seeded.readerUserId, permissions: ['send'] },
          { userId: seeded.viewerUserId, permissions: ['view'] }
        ]
      );
      const mailFromCount = smtpMailFromCommands(smtp).length;
      const readerRelayMailFromCount = smtpMailFromCommands(readerSmtp).length;

      const forbiddenRelay = await sendApiMessage(fixture.baseUrl, {
        cookie: readerCookie,
        from: 'shared@access-owner.example',
        subject: 'Shared mailbox cannot use grantee relay',
        smtpRelayId: seeded.readerRelayId
      });
      assert.equal(forbiddenRelay.status, 403, JSON.stringify(forbiddenRelay.body));
      assert.match(forbiddenRelay.body.error, /域名所有者.*SMTP 出口/);
      assert.equal(smtpMailFromCommands(smtp).length, mailFromCount);
      assert.equal(smtpMailFromCommands(readerSmtp).length, readerRelayMailFromCount);

      for (const from of ['shared@access-owner.example', 'shared-alias@access-owner.example']) {
        const sent = await sendApiMessage(fixture.baseUrl, {
          cookie: readerCookie,
          from,
          subject: `Mailbox grant send from ${from}`
        });
        assert.equal(sent.status, 202, JSON.stringify(sent.body));
        assert.equal(sent.body.queued, true);
      }

      await assertSendDenied(fixture.baseUrl, {
        cookie: readerCookie,
        from: 'other@access-owner.example'
      });

      assert.deepEqual(
        smtpMailFromCommands(smtp).slice(mailFromCount),
        [
          'MAIL FROM:<shared@access-owner.example>',
          'MAIL FROM:<shared-alias@access-owner.example>'
        ]
      );
    });
  } finally {
    fixture.child.kill('SIGTERM');
    await waitForExit(fixture.child, 1000);
    await smtp.close();
    await readerSmtp.close();
  }
});

function seedMailboxAccessFixtures(dataDir, sessionSecret, readerRelayPort) {
  const script = `
    import {
      createDomain,
      createInboundMailbox,
      createInboundMessage,
      createUser,
      initDatabase,
      saveSmtpRelay
    } from './src/db.js';
    import { createDkimKeyPair } from './src/dkim.js';

    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
    const owner = createUser({
      username: 'access-owner',
      email: 'access-owner@example.test',
      password: 'password123',
      status: 'active'
    });
    const reader = createUser({
      username: 'access-reader',
      email: 'access-reader@example.test',
      password: 'password123',
      status: 'active'
    });
    const viewer = createUser({
      username: 'access-viewer',
      email: 'access-viewer@example.test',
      password: 'password123',
      status: 'active'
    });
    const readerRelay = saveSmtpRelay(reader.id, {
      name: 'Reader private relay',
      host: '127.0.0.1',
      port: Number(process.env.READER_RELAY_PORT),
      secure: false,
      username: '',
      password: '',
      helo: 'mail.access-reader.example'
    });
    const keys = createDkimKeyPair();
    const createUserDomain = (user, name) => createDomain(user.id, {
      domain: name,
      selector: 'mh',
      verificationToken: 'verify-' + name,
      dkimPublic: keys.publicKey,
      dkimPrivate: keys.privateKey,
      senderHost: 'mail.' + name,
      sendingIp: '127.0.0.1',
      spfExtra: '',
      dmarcPolicy: 'none',
      dmarcRua: ''
    });
    createUserDomain(owner, 'access-owner.example');
    createUserDomain(reader, 'access-reader.example');

    const sharedMailbox = createInboundMailbox(owner.id, {
      address: 'shared@access-owner.example',
      password: 'mailbox-password',
      aliases: ['shared-alias']
    });
    const otherMailbox = createInboundMailbox(owner.id, {
      address: 'other@access-owner.example',
      password: 'mailbox-password'
    });
    const readerMailbox = createInboundMailbox(reader.id, {
      address: 'own@access-reader.example',
      password: 'mailbox-password'
    });
    const createMessage = (mailbox, subject, sequence) => createInboundMessage(mailbox, {
      sender: 'sender@example.net',
      recipients: [mailbox.address],
      subject,
      messageId: '<mailbox-access-' + sequence + '@example.net>',
      rawMessage: 'Subject: ' + subject + '\\r\\n\\r\\n' + subject,
      textBody: subject,
      receivedAt: '2026-07-18T0' + sequence + ':00:00.000Z'
    });
    const sharedMessage = createMessage(sharedMailbox, 'Shared grant message', 1);
    createMessage(otherMailbox, 'Other owner message', 2);
    const readerMessage = createMessage(readerMailbox, 'Reader owned message', 3);

    console.log(JSON.stringify({
      ownerUserId: owner.id,
      readerUserId: reader.id,
      viewerUserId: viewer.id,
      sharedMailboxId: sharedMailbox.id,
      otherMailboxId: otherMailbox.id,
      readerMailboxId: readerMailbox.id,
      readerRelayId: readerRelay.id,
      sharedMessageId: sharedMessage.id,
      readerMessageId: readerMessage.id
    }));
  `;
  const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
    cwd: process.cwd(),
    env: {
      ...process.env,
      DATA_DIR: dataDir,
      SESSION_SECRET: sessionSecret,
      READER_RELAY_PORT: String(readerRelayPort)
    },
    encoding: 'utf8'
  });
  assert.equal(result.status, 0, result.stderr || result.stdout);
  return JSON.parse(result.stdout);
}

async function replaceGrants(baseUrl, adminCookie, mailboxId, grants) {
  const response = await requestJson(
    baseUrl,
    `/api/admin/inbound-mailboxes/${mailboxId}/access`,
    {
      method: 'PUT',
      cookie: adminCookie,
      body: { grants }
    }
  );
  assert.equal(response.status, 200, JSON.stringify(response.body));
  return response.body.mailbox;
}

function findAdminMailbox(mailboxes, mailboxId) {
  const mailbox = mailboxes.find((entry) => entry.mailbox.id === mailboxId);
  assert.ok(mailbox, `expected admin mailbox ${mailboxId}`);
  return mailbox;
}

function grantPermissions(mailboxAccess, userId) {
  const grant = mailboxAccess.grants.find((entry) => entry.user.id === userId);
  assert.ok(grant, `expected grant for user ${userId}`);
  return grant.permissions;
}

async function mailboxAddresses(baseUrl, pathname, cookie) {
  const response = await requestJson(baseUrl, pathname, { cookie });
  assert.equal(response.status, 200, JSON.stringify(response.body));
  return response.body.mailboxes.map((mailbox) => mailbox.address).sort();
}

async function createToken(baseUrl, cookie, input) {
  const response = await requestJson(baseUrl, '/api/api-tokens', {
    method: 'POST',
    cookie,
    body: input
  });
  assert.equal(response.status, 201, JSON.stringify(response.body));
  assert.ok(response.body.token.token);
  return response.body.token;
}

function sendApiMessage(baseUrl, {
  cookie = '',
  bearer = '',
  from,
  subject = 'Mailbox grant send',
  smtpRelayId = null
}) {
  return requestJson(baseUrl, '/api/send', {
    method: 'POST',
    cookie,
    bearer,
    body: {
      from,
      to: 'recipient@example.net',
      subject,
      text: 'mailbox grant send test',
      ...(smtpRelayId ? { smtpRelayId } : {})
    }
  });
}

async function assertSendDenied(baseUrl, input) {
  const response = await sendApiMessage(baseUrl, {
    ...input,
    subject: 'Mailbox grant denial'
  });
  assert.equal(response.status, 403, JSON.stringify(response.body));
  assert.match(response.body.error, /未获得发信权限/);
}

function smtpMailFromCommands(smtp) {
  return smtp.commands.filter((command) => command.startsWith('MAIL FROM:'));
}

async function requestJson(baseUrl, pathname, {
  method = 'GET',
  cookie = '',
  bearer = '',
  body
} = {}) {
  const headers = {};
  if (cookie) headers.Cookie = cookie;
  if (bearer) headers.Authorization = `Bearer ${bearer}`;
  if (body !== undefined) headers['Content-Type'] = 'application/json';
  const response = await fetch(`${baseUrl}${pathname}`, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
    redirect: 'manual'
  });
  const text = await response.text();
  return {
    status: response.status,
    body: text ? JSON.parse(text) : null
  };
}

async function login(baseUrl, username, password) {
  const response = await fetch(`${baseUrl}/api/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password })
  });
  assert.equal(response.status, 200);
  const cookie = response.headers.get('set-cookie')?.split(';')[0] || '';
  assert.ok(cookie);
  return cookie;
}

async function startTestServer(smtpPort) {
  const port = await freePort();
  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-access-'));
  const sessionSecret = 'mailbox-access-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.test',
      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',
      SMTP_HOST: '127.0.0.1',
      SMTP_PORT: String(smtpPort),
      SMTP_SECURE: 'false',
      SMTP_HELO: 'mailhub-access.test'
    },
    stdio: ['ignore', 'pipe', 'pipe']
  });
  await waitForOutput(child, 'MailHub listening');
  return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
}

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 ACCESS123\r\n');
          }
          continue;
        }
        commands.push(line);
        if (line.startsWith('EHLO')) socket.write('250 relay.test\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))
      });
    });
  });
}

function freePort() {
  return new Promise((resolve, reject) => {
    const server = net.createServer();
    server.listen(0, '127.0.0.1', () => {
      const address = server.address();
      server.close(() => {
        if (address && typeof address === 'object') resolve(address.port);
        else reject(new Error('Unable to allocate a test port.'));
      });
    });
    server.on('error', reject);
  });
}

function waitForOutput(child, text, timeoutMs = 8000) {
  return new Promise((resolve, reject) => {
    let output = '';
    const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}\n${output}`)), timeoutMs);
    const onData = (chunk) => {
      output += chunk.toString();
      if (!output.includes(text)) return;
      clearTimeout(timeout);
      child.stdout.off('data', onData);
      child.stderr.off('data', onData);
      resolve();
    };
    child.stdout.on('data', onData);
    child.stderr.on('data', onData);
    child.once('exit', (code) => {
      clearTimeout(timeout);
      reject(new Error(`Server exited before startup with code ${code}\n${output}`));
    });
  });
}

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