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

import { AuthenticationRateLimiter } from '../src/auth-rate-limit.js';
import { createDovecotAuthServer } from '../src/dovecot-auth-server.js';

const sharedSecret = 'mailhub-dovecot-auth-test-secret-0123456789';

test('Dovecot authentication bridge requires a strong file-backed secret', () => {
  assert.throws(
    () => createDovecotAuthServer({ secret: sharedSecret }),
    /secret file is required/
  );
  assert.throws(
    () => createDovecotAuthServer({ secretFile: writeSecret('short') }),
    /32-512 byte token/
  );
});

test('Dovecot authentication bridge validates transport and returns fixed DTOs', async () => {
  const verifierCalls = [];
  const errors = [];
  const server = createDovecotAuthServer({
    secretFile: writeSecret(sharedSecret),
    verifyCredential(username, password) {
      verifierCalls.push({ username, password });
      if (password === 'throw-error') throw new Error(`sensitive ${password}`);
      if (password !== 'correct-password') return null;
      return {
        user: { id: 42, role: 'admin' },
        mailbox: {
          id: 7,
          address: 'Alice@Example.com',
          passwordHash: 'must-not-leak',
          forwardTo: ['private@example.net']
        }
      };
    },
    logger: {
      error(message) {
        errors.push(message);
      }
    }
  });
  await listen(server);

  try {
    const unauthorized = await request(server, { secret: 'wrong-secret' });
    assert.equal(unauthorized.status, 401);
    assert.equal(unauthorized.headers['cache-control'], 'no-store');
    assert.deepEqual(unauthorized.json, { error: 'Unauthorized.' });
    assert.equal(verifierCalls.length, 0);

    const wrongMethod = await request(server, { method: 'GET', body: undefined });
    assert.equal(wrongMethod.status, 405);
    assert.equal(wrongMethod.headers.allow, 'POST');

    const wrongContentType = await request(server, { contentType: 'text/plain' });
    assert.equal(wrongContentType.status, 415);

    const invalidIp = await request(server, { body: authBody({ remoteIp: 'not-an-ip' }) });
    assert.equal(invalidIp.status, 400);
    assert.equal(verifierCalls.length, 0);

    const malformed = await request(server, { rawBody: '{"username":' });
    assert.equal(malformed.status, 400);
    assert.equal(verifierCalls.length, 0);

    const oversized = await request(server, {
      body: authBody({ password: 'x'.repeat(9 * 1024) })
    });
    assert.equal(oversized.status, 413);
    assert.equal(verifierCalls.length, 0);

    const streamedOversized = await request(server, {
      body: authBody({ password: 'x'.repeat(9 * 1024) }),
      includeContentLength: false
    });
    assert.equal(streamedOversized.status, 413);
    assert.equal(verifierCalls.length, 0);

    const failed = await request(server, { body: authBody({ password: 'wrong-password' }) });
    assert.equal(failed.status, 200);
    assert.deepEqual(failed.json, { authenticated: false });
    assert.equal(failed.headers['cache-control'], 'no-store');

    const succeeded = await request(server, { body: authBody({ password: 'correct-password' }) });
    assert.equal(succeeded.status, 200);
    assert.deepEqual(succeeded.json, {
      authenticated: true,
      user: 'alice@example.com'
    });
    assert.equal(JSON.stringify(succeeded.json).includes('must-not-leak'), false);
    assert.equal(JSON.stringify(succeeded.json).includes('private@example.net'), false);

    const pop3Succeeded = await request(server, {
      body: authBody({ password: 'correct-password', service: 'pop3' })
    });
    assert.equal(pop3Succeeded.status, 200);
    assert.deepEqual(pop3Succeeded.json, {
      authenticated: true,
      user: 'alice@example.com'
    });

    const unavailable = await request(server, { body: authBody({ password: 'throw-error' }) });
    assert.equal(unavailable.status, 503);
    assert.deepEqual(unavailable.json, { error: 'Service unavailable.' });
    assert.deepEqual(errors, ['Dovecot authentication bridge request failed.']);
    assert.equal(errors.join(' ').includes('throw-error'), false);
    assert.equal(errors.join(' ').includes(sharedSecret), false);
  } finally {
    await close(server);
  }
});

test('Dovecot authentication bridge caches only successful credential checks', async () => {
  let verifierCalls = 0;
  const server = createDovecotAuthServer({
    secretFile: writeSecret(sharedSecret),
    authCacheTtlMs: 60_000,
    verifyCredential(_username, password) {
      verifierCalls += 1;
      if (password !== 'correct-password') return null;
      return { mailbox: { address: 'Alice@Example.com' } };
    }
  });
  await listen(server);

  try {
    const first = await request(server, { body: authBody({ password: 'correct-password' }) });
    assert.deepEqual(first.json, { authenticated: true, user: 'alice@example.com' });

    const cached = await request(server, { body: authBody({ password: 'correct-password' }) });
    assert.deepEqual(cached.json, { authenticated: true, user: 'alice@example.com' });
    assert.equal(verifierCalls, 1);

    const failed = await request(server, { body: authBody({ password: 'wrong-password' }) });
    assert.deepEqual(failed.json, { authenticated: false });
    const failedAgain = await request(server, { body: authBody({ password: 'wrong-password' }) });
    assert.deepEqual(failedAgain.json, { authenticated: false });
    assert.equal(verifierCalls, 3);
  } finally {
    await close(server);
  }
});

test('Dovecot authentication bridge coalesces concurrent credential checks', async () => {
  let verifierCalls = 0;
  let releaseVerifier;
  const verifierGate = new Promise((resolve) => {
    releaseVerifier = resolve;
  });
  const server = createDovecotAuthServer({
    secretFile: writeSecret(sharedSecret),
    verifyCredential: async (_username, password) => {
      verifierCalls += 1;
      await verifierGate;
      return password === 'correct-password'
        ? { mailbox: { address: 'Alice@Example.com' } }
        : null;
    }
  });
  await listen(server);

  try {
    const responses = Promise.all([
      request(server, { body: authBody({ password: 'correct-password' }) }),
      request(server, { body: authBody({ password: 'correct-password' }) }),
      request(server, { body: authBody({ password: 'correct-password' }) })
    ]);
    await waitFor(() => verifierCalls === 1);
    releaseVerifier();
    for (const response of await responses) {
      assert.equal(response.status, 200);
      assert.deepEqual(response.json, { authenticated: true, user: 'alice@example.com' });
    }
    assert.equal(verifierCalls, 1);
  } finally {
    await close(server);
  }
});

test('Dovecot authentication bridge applies the shared limiter to the supplied remote IP', async () => {
  let verifierCalls = 0;
  const limiter = new AuthenticationRateLimiter({
    combinationLimit: 1,
    accountLimit: 10,
    ipLimit: 10
  });
  const server = createDovecotAuthServer({
    secretFile: writeSecret(sharedSecret),
    authRateLimiter: limiter,
    verifyCredential(_username, password) {
      verifierCalls += 1;
      return password === 'correct-password'
        ? { mailbox: { address: 'user@example.com' } }
        : null;
    }
  });
  await listen(server);

  try {
    const failure = await request(server, {
      body: authBody({ password: 'wrong-password', remoteIp: '203.0.113.10' })
    });
    assert.deepEqual(failure.json, { authenticated: false });

    const blocked = await request(server, {
      body: authBody({ password: 'correct-password', remoteIp: '203.0.113.10' })
    });
    assert.deepEqual(blocked.json, { authenticated: false });
    assert.equal(verifierCalls, 1);

    const otherIp = await request(server, {
      body: authBody({ password: 'correct-password', remoteIp: '203.0.113.11' })
    });
    assert.deepEqual(otherIp.json, { authenticated: true, user: 'user@example.com' });
    assert.equal(verifierCalls, 2);
  } finally {
    await close(server);
  }
});

test('Dovecot authentication bridge rejects mailbox addresses that could escape a home path', async () => {
  const unsafeAddresses = [
    '../escape@example.com',
    'escape\\child@example.com',
    'nul\u0000byte@example.com',
    ' leading@example.com',
    'space user@example.com'
  ];

  for (const address of unsafeAddresses) {
    const server = createDovecotAuthServer({
      secretFile: writeSecret(sharedSecret),
      verifyCredential() {
        return { mailbox: { address } };
      },
      logger: { error() {} }
    });
    await listen(server);
    try {
      const response = await request(server, {
        body: authBody({ password: 'correct-password' })
      });
      assert.equal(response.status, 503);
      assert.deepEqual(response.json, { error: 'Service unavailable.' });
    } finally {
      await close(server);
    }
  }
});

function writeSecret(value) {
  const directory = mkdtempSync(path.join(tmpdir(), 'mailhub-dovecot-auth-'));
  const file = path.join(directory, 'secret');
  writeFileSync(file, `${value}\n`, { mode: 0o600 });
  return file;
}

function authBody(patch = {}) {
  return {
    username: 'alice@example.com',
    password: 'wrong-password',
    service: 'imap',
    remoteIp: '203.0.113.10',
    ...patch
  };
}

function listen(server) {
  server.listen(0, '127.0.0.1');
  return new Promise((resolve, reject) => {
    server.once('listening', resolve);
    server.once('error', reject);
  });
}

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

async function waitFor(predicate) {
  for (let attempt = 0; attempt < 50; attempt += 1) {
    if (predicate()) return;
    await new Promise((resolve) => setTimeout(resolve, 10));
  }
  assert.fail('Timed out waiting for condition');
}

function request(server, {
  method = 'POST',
  requestPath = '/internal/dovecot/auth',
  secret = sharedSecret,
  contentType = 'application/json',
  body = authBody(),
  rawBody: suppliedRawBody,
  includeContentLength = true
} = {}) {
  const rawBody = suppliedRawBody ?? (body === undefined ? '' : JSON.stringify(body));
  return new Promise((resolve, reject) => {
    const headers = {
      Authorization: `Bearer ${secret}`,
      'Content-Type': contentType
    };
    if (includeContentLength) headers['Content-Length'] = String(Buffer.byteLength(rawBody));
    const req = http.request({
      host: '127.0.0.1',
      port: server.address().port,
      path: requestPath,
      method,
      headers
    }, (res) => {
      const chunks = [];
      res.on('data', (chunk) => chunks.push(chunk));
      res.on('end', () => {
        const raw = Buffer.concat(chunks).toString('utf8');
        resolve({
          status: res.statusCode,
          headers: res.headers,
          json: raw ? JSON.parse(raw) : null
        });
      });
    });
    req.once('error', reject);
    req.end(rawBody);
  });
}
