import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { chmodSync, mkdtempSync, rmSync, writeFileSync } 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';

const webmailUrl = 'https://mail.us.ss5.xyz/';
const webmailAudience = 'https://mail.us.ss5.xyz';
const sharedSecret = 'cd'.repeat(32);
const userPassword = 'password123';

test('Webmail SSO requires an authorized MailHub session and safely exchanges one-time tickets', async (t) => {
  const fixture = await startTestServer();
  t.after(async () => {
    await stopChild(fixture.child);
    rmSync(fixture.tempDir, { recursive: true, force: true });
  });

  const ownerCookie = await login(fixture.baseUrl, 'sso-owner', userPassword);
  const receiverCookie = await login(fixture.baseUrl, 'sso-receiver', userPassword);
  const viewerCookie = await login(fixture.baseUrl, 'sso-viewer', userPassword);
  const adminCookie = await login(fixture.baseUrl, 'sso-admin', userPassword);
  const loginPath = `/api/inbound-mailboxes/${fixture.seed.mailboxId}/webmail-login`;

  await t.test('anonymous, Basic, and Bearer requests cannot issue a login ticket', async () => {
    const anonymous = await requestJson(fixture.baseUrl, loginPath, {
      method: 'POST',
      origin: fixture.baseUrl
    });
    assert.equal(anonymous.status, 401);
    assertNoOpaqueCredential(anonymous.raw);

    const basic = await requestJson(fixture.baseUrl, loginPath, {
      method: 'POST',
      origin: fixture.baseUrl,
      authorization: `Basic ${Buffer.from(`sso-owner:${userPassword}`).toString('base64')}`
    });
    assert.equal(basic.status, 403);
    assertNoOpaqueCredential(basic.raw);

    const bearer = await requestJson(fixture.baseUrl, loginPath, {
      method: 'POST',
      origin: fixture.baseUrl,
      authorization: `Bearer ${fixture.seed.ownerToken}`
    });
    assert.equal(bearer.status, 401);
    assertNoOpaqueCredential(bearer.raw);
  });

  await t.test('ticket issuance rejects missing and cross-origin requests', async () => {
    const missingOrigin = await requestJson(fixture.baseUrl, loginPath, {
      method: 'POST',
      cookie: ownerCookie
    });
    assert.equal(missingOrigin.status, 403);
    assertNoOpaqueCredential(missingOrigin.raw);

    const crossOrigin = await requestJson(fixture.baseUrl, loginPath, {
      method: 'POST',
      cookie: ownerCookie,
      origin: 'https://evil.example'
    });
    assert.equal(crossOrigin.status, 403);
    assertNoOpaqueCredential(crossOrigin.raw);
  });

  let ownerTicket = '';
  await t.test('the owner and receive delegate can issue private one-time tickets', async () => {
    const owner = await issueTicket(fixture.baseUrl, loginPath, ownerCookie);
    ownerTicket = owner.body.webmailLogin.ticket;
    assert.equal(owner.body.webmailLogin.action, webmailUrl);
    assert.match(ownerTicket, /^mht_[A-Za-z0-9_-]{43}$/);
    assert.ok(Date.parse(owner.body.webmailLogin.expiresAt) > Date.now());

    const receiver = await issueTicket(fixture.baseUrl, loginPath, receiverCookie);
    assert.equal(receiver.body.webmailLogin.action, webmailUrl);
    assert.match(receiver.body.webmailLogin.ticket, /^mht_[A-Za-z0-9_-]{43}$/);
    assert.notEqual(receiver.body.webmailLogin.ticket, ownerTicket);
  });

  await t.test('view-only users and administrators have no implicit login access', async () => {
    for (const cookie of [viewerCookie, adminCookie]) {
      const response = await requestJson(fixture.baseUrl, loginPath, {
        method: 'POST',
        cookie,
        origin: fixture.baseUrl
      });
      assert.equal(response.status, 404);
      assertNoOpaqueCredential(response.raw);
      assert.equal(response.raw.includes(fixture.seed.mailboxAddress), false);
    }
  });

  await t.test('the internal exchange authenticates, consumes once, and revokes the credential', async () => {
    const exchangePath = '/internal/webmail-sso/exchange';
    const wrongSecret = await requestJson(fixture.baseUrl, exchangePath, {
      method: 'POST',
      authorization: 'Bearer definitely-wrong-secret',
      body: { ticket: ownerTicket, audience: webmailAudience }
    });
    assert.equal(wrongSecret.status, 401);
    assertPrivateResponse(wrongSecret);
    assert.equal(wrongSecret.raw.includes(ownerTicket), false);
    assertNoOpaqueCredential(wrongSecret.raw);

    const exchanged = await requestJson(fixture.baseUrl, exchangePath, {
      method: 'POST',
      authorization: `Bearer ${sharedSecret}`,
      body: { ticket: ownerTicket, audience: webmailAudience }
    });
    assert.equal(exchanged.status, 200, exchanged.raw);
    assertPrivateResponse(exchanged);
    assert.equal(exchanged.body.username, fixture.seed.mailboxAddress);
    assert.match(exchanged.body.credential, /^mhw_[A-Za-z0-9_-]{43}$/);
    assert.ok(Date.parse(exchanged.body.expiresAt) > Date.now());
    const credential = exchanged.body.credential;

    const replay = await requestJson(fixture.baseUrl, exchangePath, {
      method: 'POST',
      authorization: `Bearer ${sharedSecret}`,
      body: { ticket: ownerTicket, audience: webmailAudience }
    });
    assert.equal(replay.status, 400);
    assertPrivateResponse(replay);
    assert.equal(replay.raw.includes(ownerTicket), false);
    assert.equal(replay.raw.includes(credential), false);
    assertNoOpaqueCredential(replay.raw);

    const revoked = await requestJson(fixture.baseUrl, '/internal/webmail-sso/revoke', {
      method: 'POST',
      authorization: `Bearer ${sharedSecret}`,
      body: { credential, audience: webmailAudience }
    });
    assert.equal(revoked.status, 200, revoked.raw);
    assertPrivateResponse(revoked);
    assert.deepEqual(revoked.body, { revoked: true });
    assert.equal(revoked.raw.includes(credential), false);
  });

  await t.test('ticket issuance returns 429 after the bounded per-user burst', async () => {
    // The owner already created one ticket earlier in this fixture.
    for (let index = 1; index < 20; index += 1) {
      const response = await requestJson(fixture.baseUrl, loginPath, {
        method: 'POST',
        cookie: ownerCookie,
        origin: fixture.baseUrl
      });
      assert.equal(response.status, 201, response.raw);
    }
    const limited = await requestJson(fixture.baseUrl, loginPath, {
      method: 'POST',
      cookie: ownerCookie,
      origin: fixture.baseUrl
    });
    assert.equal(limited.status, 429, limited.raw);
    assert.equal(limited.headers.get('retry-after'), '60');
    assert.match(limited.body.error, /请求过于频繁/);
    assertNoOpaqueCredential(limited.raw);
  });
});

async function issueTicket(baseUrl, pathname, cookie) {
  const response = await requestJson(baseUrl, pathname, {
    method: 'POST',
    cookie,
    origin: baseUrl
  });
  assert.equal(response.status, 201, response.raw);
  assertPrivateResponse(response);
  return response;
}

async function startTestServer() {
  const port = await freePort();
  const baseUrl = `http://127.0.0.1:${port}`;
  const tempDir = mkdtempSync(path.join(tmpdir(), 'mailhub-server-webmail-sso-'));
  const dataDir = path.join(tempDir, 'data');
  const secretFile = path.join(tempDir, 'webmail-sso-secret');
  const sessionSecret = 'mailhub-webmail-server-session-secret';
  writeFileSync(secretFile, `${sharedSecret}\n`, { mode: 0o600 });
  chmodSync(secretFile, 0o600);
  const seed = seedFixture(dataDir, sessionSecret);

  const child = spawn(process.execPath, ['src/server.js'], {
    cwd: process.cwd(),
    env: {
      ...process.env,
      PORT: String(port),
      DATA_DIR: dataDir,
      APP_BASE_URL: baseUrl,
      SESSION_SECRET: sessionSecret,
      ADMIN_USER: 'sso-admin',
      ADMIN_EMAIL: 'sso-admin@example.test',
      ADMIN_PASSWORD: userPassword,
      WEBMAIL_SSO_URL: webmailUrl,
      WEBMAIL_SSO_SECRET_FILE: secretFile,
      WEBMAIL_SSO_TICKET_TTL_SECONDS: '60',
      WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS: '3600',
      DNS_AUTO_CHECK_ENABLED: 'false',
      DELIVERY_TRACKING_ENABLED: 'false',
      WEBHOOK_WORKER_ENABLED: 'false',
      SUBMISSION_ENABLED: 'false',
      IMAP_ENABLED: 'false',
      POP3_ENABLED: 'false',
      DOVECOT_AUTH_ENABLED: 'false',
      MAIL_ACCESS_BACKEND: 'legacy'
    },
    stdio: ['ignore', 'pipe', 'pipe']
  });

  try {
    await waitForServer(child, baseUrl);
    return { child, baseUrl, tempDir, seed };
  } catch (error) {
    await stopChild(child);
    rmSync(tempDir, { recursive: true, force: true });
    throw error;
  }
}

function seedFixture(dataDir, sessionSecret) {
  const script = `
    import {
      createApiToken,
      createDomain,
      createInboundMailbox,
      createUser,
      initDatabase,
      replaceInboundMailboxGrants
    } from './src/db.js';

    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
    const admin = createUser({
      username: 'sso-admin',
      email: 'sso-admin@example.test',
      password: '${userPassword}',
      role: 'admin',
      status: 'active'
    });
    const owner = createUser({
      username: 'sso-owner',
      email: 'sso-owner@example.test',
      password: '${userPassword}',
      status: 'active'
    });
    const receiver = createUser({
      username: 'sso-receiver',
      email: 'sso-receiver@example.test',
      password: '${userPassword}',
      status: 'active'
    });
    const viewer = createUser({
      username: 'sso-viewer',
      email: 'sso-viewer@example.test',
      password: '${userPassword}',
      status: 'active'
    });
    createDomain(owner.id, {
      domain: 'webmail-sso.example',
      selector: 'mh',
      verificationToken: 'verify-webmail-sso',
      dkimPublic: 'test-public-key',
      dkimPrivate: 'test-private-key',
      senderHost: 'mail.webmail-sso.example',
      sendingIp: '127.0.0.1',
      spfExtra: '',
      dmarcPolicy: 'none',
      dmarcRua: ''
    });
    const mailbox = createInboundMailbox(owner.id, {
      address: 'shared@webmail-sso.example',
      password: 'mailbox-password'
    });
    replaceInboundMailboxGrants(mailbox.id, [
      { userId: receiver.id, permissions: ['receive'] },
      { userId: viewer.id, permissions: ['view'] }
    ]);
    const ownerToken = createApiToken(owner.id, 'Webmail route rejection', {
      scopes: ['messages:read'],
      mailboxAccess: 'owner'
    });
    console.log(JSON.stringify({
      adminUserId: admin.id,
      ownerUserId: owner.id,
      receiverUserId: receiver.id,
      viewerUserId: viewer.id,
      mailboxId: mailbox.id,
      mailboxAddress: mailbox.address,
      ownerToken: ownerToken.token
    }));
  `;
  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 login(baseUrl, username, password) {
  const response = await requestJson(baseUrl, '/api/login', {
    method: 'POST',
    body: { username, password }
  });
  assert.equal(response.status, 200, response.raw);
  const cookie = response.headers.get('set-cookie')?.split(';', 1)[0] || '';
  assert.ok(cookie);
  return cookie;
}

async function requestJson(baseUrl, pathname, {
  method = 'GET',
  cookie = '',
  origin = '',
  authorization = '',
  body
} = {}) {
  const headers = {};
  if (cookie) headers.Cookie = cookie;
  if (origin) headers.Origin = origin;
  if (authorization) headers.Authorization = authorization;
  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 raw = await response.text();
  return {
    status: response.status,
    headers: response.headers,
    raw,
    body: raw ? JSON.parse(raw) : null
  };
}

function assertPrivateResponse(response) {
  assert.match(response.headers.get('cache-control') || '', /(?:^|[,\s])no-store(?:[,\s]|$)/);
  assert.equal(response.headers.get('pragma'), 'no-cache');
}

function assertNoOpaqueCredential(raw) {
  assert.doesNotMatch(raw, /\bmht_[A-Za-z0-9_-]+\b/);
  assert.doesNotMatch(raw, /\bmhw_[A-Za-z0-9_-]+\b/);
}

async function waitForServer(child, baseUrl, timeoutMs = 8000) {
  let output = '';
  child.stdout.on('data', (chunk) => { output += chunk.toString(); });
  child.stderr.on('data', (chunk) => { output += chunk.toString(); });
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    if (child.exitCode !== null) {
      throw new Error(`MailHub exited before becoming ready (code ${child.exitCode}).\n${output}`);
    }
    try {
      const response = await fetch(`${baseUrl}/healthz`);
      if (response.status === 200) return;
    } catch {
      // The child process may still be binding its HTTP listener.
    }
    await delay(50);
  }
  throw new Error(`Timed out waiting for MailHub.\n${output}`);
}

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

async function stopChild(child) {
  if (!child || child.exitCode !== null) return;
  child.kill('SIGTERM');
  if (await waitForExit(child, 1500)) return;
  child.kill('SIGKILL');
  await waitForExit(child, 1000);
}

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

function delay(milliseconds) {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
