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('API tokens enforce inbound mailbox scope and keep token management session-only', async (t) => {
  const fixture = await startTestServer();

  try {
    const seeded = seedInboundAccessFixtures(fixture.dataDir, fixture.sessionSecret);
    const adminCookie = await login(fixture.baseUrl, 'admin', 'password123');
    const securityAdminCookie = await login(fixture.baseUrl, 'token-security-admin', 'password123');
    const aliceCookie = await login(fixture.baseUrl, 'token-alice', 'password123');
    const bobCookie = await login(fixture.baseUrl, 'token-bob', 'password123');

    const sendOnly = await createToken(fixture.baseUrl, aliceCookie, {
      name: 'alice send only',
      scopes: ['send']
    });
    const aliceOwner = await createToken(fixture.baseUrl, aliceCookie, {
      name: 'alice owner messages',
      scopes: ['messages:read'],
      mailboxAccess: 'owner'
    });
    const aliceSelected = await createToken(fixture.baseUrl, aliceCookie, {
      name: 'alice selected messages',
      scopes: ['messages:read', 'mailboxes:read'],
      mailboxAccess: 'selected',
      mailboxIds: [seeded.alicePrimaryMailboxId]
    });
    const adminSelected = await createToken(fixture.baseUrl, adminCookie, {
      name: 'admin selected bob',
      scopes: ['messages:read', 'mailboxes:read'],
      mailboxAccess: 'selected',
      mailboxIds: [seeded.bobMailboxId]
    });
    const adminAll = await createToken(fixture.baseUrl, adminCookie, {
      name: 'admin all messages',
      scopes: ['messages:read', 'mailboxes:read'],
      mailboxAccess: 'all'
    });

    await t.test('Bearer message reads are scope-limited, isolated, and read-only', async () => {
      const deniedByScope = await requestJson(fixture.baseUrl, '/api/inbound-messages', {
        bearer: sendOnly.token
      });
      assert.equal(deniedByScope.status, 403);
      assert.match(deniedByScope.body.error, /messages:read/);

      const ownerList = await requestJson(fixture.baseUrl, '/api/inbound-messages?page=1&pageSize=100', {
        bearer: aliceOwner.token
      });
      assert.equal(ownerList.status, 200);
      assert.equal(ownerList.body.total, 2);
      assert.deepEqual(
        new Set(ownerList.body.messages.map((message) => message.mailboxId)),
        new Set([seeded.alicePrimaryMailboxId, seeded.aliceSecondaryMailboxId])
      );
      assert.equal(ownerList.body.messages.some((message) => message.mailboxId === seeded.bobMailboxId), false);

      const ownerCrossUserFilter = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages?mailboxId=${seeded.bobMailboxId}`,
        { bearer: aliceOwner.token }
      );
      assert.equal(ownerCrossUserFilter.status, 200);
      assert.equal(ownerCrossUserFilter.body.total, 0);
      assert.deepEqual(ownerCrossUserFilter.body.messages, []);

      const selectedList = await requestJson(fixture.baseUrl, '/api/inbound-messages?page=1&pageSize=100', {
        bearer: aliceSelected.token
      });
      assert.equal(selectedList.status, 200);
      assert.equal(selectedList.body.total, 1);
      assert.deepEqual(selectedList.body.messages.map((message) => message.id), [seeded.alicePrimaryMessageId]);

      const selectedMailboxes = await requestJson(fixture.baseUrl, '/api/mailboxes', {
        bearer: aliceSelected.token
      });
      assert.equal(selectedMailboxes.status, 200);
      assert.deepEqual(selectedMailboxes.body.mailboxes.map((mailbox) => mailbox.id), [seeded.alicePrimaryMailboxId]);

      const selectedUnlistedFilter = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages?mailboxId=${seeded.aliceSecondaryMailboxId}`,
        { bearer: aliceSelected.token }
      );
      assert.equal(selectedUnlistedFilter.status, 200);
      assert.equal(selectedUnlistedFilter.body.total, 0);

      const selectedAuthorizedDetail = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages/${seeded.alicePrimaryMessageId}`,
        { bearer: aliceSelected.token }
      );
      assert.equal(selectedAuthorizedDetail.status, 200);
      assert.equal(selectedAuthorizedDetail.body.message.subject, 'Alice primary message');

      for (const [pathname, bearer] of [
        [`/api/inbound-messages/${seeded.bobMessageId}`, aliceOwner.token],
        [`/api/inbound-messages/${seeded.aliceSecondaryMessageId}`, aliceSelected.token],
        [`/api/inbound-mailboxes/${seeded.bobMailboxId}/folders`, aliceOwner.token],
        [`/api/inbound-mailboxes/${seeded.aliceSecondaryMailboxId}/folders`, aliceSelected.token]
      ]) {
        const hidden = await requestJson(fixture.baseUrl, pathname, { bearer });
        assert.equal(hidden.status, 404, pathname);
      }

      const adminSelectedList = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', {
        bearer: adminSelected.token
      });
      assert.equal(adminSelectedList.status, 200);
      assert.equal(adminSelectedList.body.total, 1);
      assert.deepEqual(adminSelectedList.body.messages.map((message) => message.id), [seeded.bobMessageId]);

      const adminSelectedMailboxes = await requestJson(fixture.baseUrl, '/api/mailboxes', {
        bearer: adminSelected.token
      });
      assert.equal(adminSelectedMailboxes.status, 200);
      assert.deepEqual(adminSelectedMailboxes.body.mailboxes.map((mailbox) => mailbox.id), [seeded.bobMailboxId]);

      const adminSelectedDetail = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages/${seeded.bobMessageId}`,
        { bearer: adminSelected.token }
      );
      assert.equal(adminSelectedDetail.status, 200);
      assert.equal(adminSelectedDetail.body.message.subject, 'Bob private message');

      const adminAllList = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', {
        bearer: adminAll.token
      });
      assert.equal(adminAllList.status, 200);
      assert.equal(adminAllList.body.total, 4);
      assert.equal(adminAllList.body.messages.length, 4);

      const adminAllMailboxList = await requestJson(fixture.baseUrl, '/api/mailboxes', {
        bearer: adminAll.token
      });
      assert.equal(adminAllMailboxList.status, 200);
      assert.equal(adminAllMailboxList.body.mailboxes.length, 4);

      const deniedMutation = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages/${seeded.alicePrimaryMessageId}`,
        {
          method: 'PATCH',
          bearer: aliceSelected.token,
          body: { read: true }
        }
      );
      assert.equal(deniedMutation.status, 401);

      const unchanged = await requestJson(
        fixture.baseUrl,
        `/api/inbound-messages/${seeded.alicePrimaryMessageId}`,
        { cookie: aliceCookie }
      );
      assert.equal(unchanged.status, 200);
      assert.equal(unchanged.body.message.read, false);
    });

    await t.test('session token management reveals recoverable values and enforces admin-only all-mailbox access', async () => {
      const aliceTokens = await requestJson(fixture.baseUrl, '/api/api-tokens', { cookie: aliceCookie });
      assert.equal(aliceTokens.status, 200);
      assert.match(aliceTokens.response.headers.get('cache-control') || '', /no-store/i);

      for (const created of [sendOnly, aliceOwner, aliceSelected]) {
        const listed = aliceTokens.body.tokens.find((token) => token.id === created.id);
        assert.ok(listed, created.name);
        assert.equal(listed.tokenRecoverable, true);
        assert.equal(listed.token, created.token);
      }

      const adminTokens = await requestJson(fixture.baseUrl, '/api/api-tokens', { cookie: adminCookie });
      assert.equal(adminTokens.status, 200);
      assert.match(adminTokens.response.headers.get('cache-control') || '', /no-store/i);
      const historical = adminTokens.body.tokens.find((token) => token.id === seeded.historicalTokenId);
      assert.ok(historical);
      assert.equal(historical.name, 'historical unrecoverable');
      assert.equal(historical.tokenRecoverable, false);
      assert.equal(Object.hasOwn(historical, 'token'), false);

      const basicAdminTokens = await requestJson(fixture.baseUrl, '/api/api-tokens', {
        basic: ['admin', 'password123']
      });
      assert.equal(basicAdminTokens.status, 200);
      assert.equal(basicAdminTokens.body.tokens.every((token) => !Object.hasOwn(token, 'token')), true);

      const ordinaryAll = await requestJson(fixture.baseUrl, '/api/api-tokens', {
        method: 'POST',
        cookie: aliceCookie,
        body: {
          name: 'ordinary all denied',
          scopes: ['messages:read'],
          mailboxAccess: 'all'
        }
      });
      assert.equal(ordinaryAll.status, 400);
      assert.match(ordinaryAll.body.error, /管理员/);

      const ordinaryCrossUserSelected = await requestJson(fixture.baseUrl, '/api/api-tokens', {
        method: 'POST',
        cookie: aliceCookie,
        body: {
          name: 'ordinary cross-user selected denied',
          scopes: ['messages:read'],
          mailboxAccess: 'selected',
          mailboxIds: [seeded.bobMailboxId]
        }
      });
      assert.equal(ordinaryCrossUserSelected.status, 400);
      assert.match(ordinaryCrossUserSelected.body.error, /不存在|无权访问/);

      const adminAllMailboxes = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?all=true', {
        cookie: adminCookie
      });
      assert.equal(adminAllMailboxes.status, 200);
      assert.equal(adminAllMailboxes.body.mailboxes.length, 4);
      assert.deepEqual(
        new Set(adminAllMailboxes.body.mailboxes.map((mailbox) => mailbox.id)),
        new Set([
          seeded.adminMailboxId,
          seeded.alicePrimaryMailboxId,
          seeded.aliceSecondaryMailboxId,
          seeded.bobMailboxId
        ])
      );

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

      const basicAdminAllMailboxes = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?all=true', {
        basic: ['admin', 'password123']
      });
      assert.equal(basicAdminAllMailboxes.status, 403);

      const rotated = await requestJson(fixture.baseUrl, `/api/api-tokens/${aliceOwner.id}/rotate`, {
        method: 'POST',
        cookie: aliceCookie
      });
      assert.equal(rotated.status, 200);
      assert.match(rotated.response.headers.get('cache-control') || '', /no-store/i);
      assert.equal(rotated.body.token.id, aliceOwner.id);
      assert.equal(rotated.body.token.tokenRecoverable, true);
      assert.ok(rotated.body.token.token);
      assert.notEqual(rotated.body.token.token, aliceOwner.token);

      const oldTokenRejected = await requestJson(fixture.baseUrl, '/api/inbound-messages', {
        bearer: aliceOwner.token
      });
      assert.equal(oldTokenRejected.status, 401);

      const newTokenAccepted = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', {
        bearer: rotated.body.token.token
      });
      assert.equal(newTokenAccepted.status, 200);
      assert.equal(newTokenAccepted.body.total, 2);

      const listAfterRotate = await requestJson(fixture.baseUrl, '/api/api-tokens', { cookie: aliceCookie });
      const rotatedSummary = listAfterRotate.body.tokens.find((token) => token.id === aliceOwner.id);
      assert.equal(rotatedSummary.token, rotated.body.token.token);
      assert.notEqual(rotatedSummary.token, aliceOwner.token);

      const demoted = await requestJson(fixture.baseUrl, `/api/admin/users/${seeded.adminUserId}`, {
        method: 'PATCH',
        cookie: securityAdminCookie,
        body: { role: 'user' }
      });
      assert.equal(demoted.status, 200);
      assert.equal(demoted.body.user.role, 'user');

      const allMessagesAfterDemotion = await requestJson(fixture.baseUrl, '/api/inbound-messages', {
        bearer: adminAll.token
      });
      assert.equal(allMessagesAfterDemotion.status, 200);
      assert.equal(allMessagesAfterDemotion.body.total, 0);

      const allMailboxesAfterDemotion = await requestJson(fixture.baseUrl, '/api/mailboxes', {
        bearer: adminAll.token
      });
      assert.equal(allMailboxesAfterDemotion.status, 200);
      assert.deepEqual(allMailboxesAfterDemotion.body.mailboxes, []);
    });
  } finally {
    fixture.child.kill('SIGTERM');
    await waitForExit(fixture.child, 1000);
  }
});

async function createToken(baseUrl, cookie, input) {
  const result = await requestJson(baseUrl, '/api/api-tokens', {
    method: 'POST',
    cookie,
    body: input
  });
  assert.equal(result.status, 201, JSON.stringify(result.body));
  assert.match(result.response.headers.get('cache-control') || '', /no-store/i);
  assert.ok(result.body.token.token);
  return result.body.token;
}

function seedInboundAccessFixtures(dataDir, sessionSecret) {
  const script = `
    import { DatabaseSync } from 'node:sqlite';
    import path from 'node:path';
    import {
      createApiToken,
      createDomain,
      createInboundMailbox,
      createInboundMessage,
      createUser,
      getUserByLogin,
      initDatabase
    } from './src/db.js';

    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
    const admin = getUserByLogin('admin');
    const alice = createUser({
      username: 'token-alice',
      email: 'token-alice@example.test',
      password: 'password123',
      status: 'active'
    });
    const bob = createUser({
      username: 'token-bob',
      email: 'token-bob@example.test',
      password: 'password123',
      status: 'active'
    });
    createUser({
      username: 'token-security-admin',
      email: 'token-security-admin@example.test',
      password: 'password123',
      role: 'admin',
      status: 'active'
    });

    const createUserDomain = (user, name) => createDomain(user.id, {
      domain: name,
      selector: 'mh',
      verificationToken: 'verify-' + name,
      dkimPublic: 'public-' + name,
      dkimPrivate: 'private-' + name,
      senderHost: 'mail.' + name,
      sendingIp: '127.0.0.1',
      spfExtra: '',
      dmarcPolicy: 'none',
      dmarcRua: ''
    });
    createUserDomain(admin, 'token-admin.example');
    createUserDomain(alice, 'token-alice.example');
    createUserDomain(bob, 'token-bob.example');

    const adminMailbox = createInboundMailbox(admin.id, {
      address: 'inbox@token-admin.example',
      password: 'mailbox-password'
    });
    const alicePrimaryMailbox = createInboundMailbox(alice.id, {
      address: 'primary@token-alice.example',
      password: 'mailbox-password'
    });
    const aliceSecondaryMailbox = createInboundMailbox(alice.id, {
      address: 'secondary@token-alice.example',
      password: 'mailbox-password'
    });
    const bobMailbox = createInboundMailbox(bob.id, {
      address: 'inbox@token-bob.example',
      password: 'mailbox-password'
    });

    const createMessage = (mailbox, subject, sequence) => createInboundMessage(mailbox, {
      sender: 'sender@example.net',
      recipients: [mailbox.address],
      subject,
      messageId: '<token-inbound-' + sequence + '@example.net>',
      rawMessage: 'Subject: ' + subject + '\\r\\n\\r\\n' + subject,
      textBody: subject,
      receivedAt: '2026-07-14T0' + sequence + ':00:00.000Z'
    });
    const adminMessage = createMessage(adminMailbox, 'Admin private message', 1);
    const alicePrimaryMessage = createMessage(alicePrimaryMailbox, 'Alice primary message', 2);
    const aliceSecondaryMessage = createMessage(aliceSecondaryMailbox, 'Alice secondary message', 3);
    const bobMessage = createMessage(bobMailbox, 'Bob private message', 4);

    const historical = createApiToken(admin.id, 'historical unrecoverable', {
      scopes: ['messages:read'],
      mailboxAccess: 'owner'
    });
    const database = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
    database.prepare("UPDATE api_tokens SET token_secret = '' WHERE id = ?").run(historical.id);
    database.close();

    console.log(JSON.stringify({
      adminUserId: admin.id,
      adminMailboxId: adminMailbox.id,
      alicePrimaryMailboxId: alicePrimaryMailbox.id,
      aliceSecondaryMailboxId: aliceSecondaryMailbox.id,
      bobMailboxId: bobMailbox.id,
      adminMessageId: adminMessage.id,
      alicePrimaryMessageId: alicePrimaryMessage.id,
      aliceSecondaryMessageId: aliceSecondaryMessage.id,
      bobMessageId: bobMessage.id,
      historicalTokenId: historical.id
    }));
  `;
  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 requestJson(baseUrl, pathname, {
  method = 'GET',
  cookie = '',
  bearer = '',
  basic = null,
  body
} = {}) {
  const headers = {};
  if (cookie) headers.Cookie = cookie;
  if (bearer) headers.Authorization = `Bearer ${bearer}`;
  if (basic) headers.Authorization = `Basic ${Buffer.from(basic.join(':')).toString('base64')}`;
  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 {
    response,
    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() {
  const port = await freePort();
  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-token-inbound-auth-'));
  const sessionSecret = 'token-inbound-auth-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'
    },
    stdio: ['ignore', 'pipe', 'pipe']
  });
  await waitForOutput(child, 'MailHub listening');
  return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
}

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.'));
      });
    });
  });
}

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

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);
    });
  });
}
