import crypto from 'node:crypto';
import { readFileSync } from 'node:fs';
import http from 'node:http';
import { isIP } from 'node:net';

import { authenticateWithRateLimitAsync, authenticationRateLimiter } from './auth-rate-limit.js';
import { verifyInboundMailboxCredentialAsync } from './db.js';

const authPath = '/internal/dovecot/auth';
const defaultBodyLimit = 8 * 1024;
const defaultRequestTimeoutMs = 30_000;
const defaultAuthCacheTtlMs = 600_000;
const defaultAuthCacheMaxEntries = 4096;

export function createDovecotAuthServer(options = {}) {
  const sharedSecretDigest = digestSecret(readSharedSecret(options.secretFile));
  const limiter = options.authRateLimiter || authenticationRateLimiter;
  const verifyCredential = options.verifyCredential || verifyInboundMailboxCredentialAsync;
  const logger = options.logger || console;
  const requestTimeoutMs = positiveInteger(options.requestTimeoutMs, defaultRequestTimeoutMs);
  const authCache = options.authCache || new SuccessfulAuthCache({
    ttlMs: options.authCacheTtlMs,
    maxEntries: options.authCacheMaxEntries,
    secretDigest: sharedSecretDigest
  });
  const inFlightAuth = options.inFlightAuth || new InFlightAuthChecks({
    maxEntries: options.inFlightAuthMaxEntries,
    secretDigest: sharedSecretDigest
  });

  const server = http.createServer((req, res) => {
    void handleRequest(req, res, {
      sharedSecretDigest,
      limiter,
      verifyCredential,
      logger,
      bodyLimit: defaultBodyLimit,
      authCache,
      inFlightAuth
    });
  });
  server.requestTimeout = requestTimeoutMs;
  server.headersTimeout = requestTimeoutMs;
  server.keepAliveTimeout = 1_000;
  return server;
}

export function startDovecotAuthServer(options = {}) {
  const server = createDovecotAuthServer(options);
  const host = String(options.host || '0.0.0.0');
  const port = Number(options.port ?? 3001);
  server.listen(port, host, () => {
    options.onListening?.(server);
  });
  return server;
}

async function handleRequest(req, res, context) {
  setPrivateHeaders(res);
  const pathname = requestPathname(req);
  if (pathname !== authPath) return sendJson(res, 404, { error: 'Not found.' });
  if (req.method !== 'POST') {
    res.setHeader('Allow', 'POST');
    return sendJson(res, 405, { error: 'Method not allowed.' });
  }
  if (!validBearerSecret(req.headers.authorization, context.sharedSecretDigest)) {
    return sendJson(res, 401, { error: 'Unauthorized.' });
  }
  if (requestContentType(req) !== 'application/json') {
    return sendJson(res, 415, { error: 'Unsupported media type.' });
  }

  const contentLength = parseContentLength(req.headers['content-length']);
  if (contentLength === null) return sendJson(res, 400, { error: 'Invalid request.' });
  if (contentLength > context.bodyLimit) return sendTooLarge(req, res);

  let body;
  try {
    body = await readJson(req, context.bodyLimit);
  } catch (error) {
    if (error instanceof RequestTooLargeError) return sendTooLarge(req, res);
    return sendJson(res, 400, { error: 'Invalid request.' });
  }

  const request = normalizeAuthRequest(body);
  if (!request) return sendJson(res, 400, { error: 'Invalid request.' });

  try {
    const authenticated = await authenticateWithRateLimitAsync({
      limiter: context.limiter,
      ip: request.remoteIp,
      account: request.username,
      authenticate: () => verifyCachedCredential(request, context)
    });
    if (!authenticated) return sendJson(res, 200, { authenticated: false });
    return sendJson(res, 200, { authenticated: true, user: authenticated.user });
  } catch {
    context.logger.error?.('Dovecot authentication bridge request failed.');
    return sendJson(res, 503, { error: 'Service unavailable.' });
  }
}

async function verifyCachedCredential(request, context) {
  const cachedUser = context.authCache?.get(request.username, request.password);
  if (cachedUser) return { user: cachedUser };

  return context.inFlightAuth.run(request.username, request.password, async () => {
    const recheckedUser = context.authCache?.get(request.username, request.password);
    if (recheckedUser) return { user: recheckedUser };

    const authenticated = await context.verifyCredential(request.username, request.password);
    if (!authenticated) return null;

    const user = canonicalMailboxAddress(authenticated);
    if (!user) throw new Error('Credential verifier returned an invalid mailbox');
    context.authCache?.set(request.username, request.password, user);
    return { user };
  });
}

function readSharedSecret(filePath) {
  if (!filePath || typeof filePath !== 'string') {
    throw new Error('Dovecot authentication secret file is required');
  }
  const secret = readFileSync(filePath, 'utf8').trim();
  const bytes = Buffer.byteLength(secret, 'utf8');
  if (bytes < 32 || bytes > 512 || /\s/.test(secret)) {
    throw new Error('Dovecot authentication secret must be a 32-512 byte token');
  }
  return secret;
}

function validBearerSecret(header, expectedDigest) {
  const match = String(header || '').match(/^Bearer\s+([^\s]+)$/i);
  const actualDigest = digestSecret(match?.[1] || '');
  return Boolean(match) && crypto.timingSafeEqual(actualDigest, expectedDigest);
}

function digestSecret(value) {
  return crypto.createHash('sha256').update(value).digest();
}

class SuccessfulAuthCache {
  constructor({
    ttlMs = defaultAuthCacheTtlMs,
    maxEntries = defaultAuthCacheMaxEntries,
    secretDigest = crypto.randomBytes(32),
    now = () => Date.now()
  } = {}) {
    this.ttlMs = Math.max(0, Number(ttlMs ?? defaultAuthCacheTtlMs) || 0);
    this.maxEntries = Math.max(0, Number(maxEntries ?? defaultAuthCacheMaxEntries) || 0);
    this.secretDigest = Buffer.from(secretDigest);
    this.now = now;
    this.entries = new Map();
  }

  get(username, password) {
    if (!this.enabled()) return '';
    const key = this.key(username, password);
    const entry = this.entries.get(key);
    if (!entry) return '';
    if (entry.expiresAt <= this.now()) {
      this.entries.delete(key);
      return '';
    }
    this.entries.delete(key);
    this.entries.set(key, entry);
    return entry.user;
  }

  set(username, password, user) {
    if (!this.enabled()) return;
    const cleanUser = String(user || '').trim().toLowerCase();
    if (!cleanUser) return;
    const key = this.key(username, password);
    this.entries.set(key, {
      user: cleanUser,
      expiresAt: this.now() + this.ttlMs
    });
    while (this.entries.size > this.maxEntries) {
      const oldestKey = this.entries.keys().next().value;
      if (oldestKey === undefined) break;
      this.entries.delete(oldestKey);
    }
  }

  enabled() {
    return this.ttlMs > 0 && this.maxEntries > 0;
  }

  key(username, password) {
    return crypto
      .createHmac('sha256', this.secretDigest)
      .update(String(username || '').trim().toLowerCase())
      .update('\0')
      .update(String(password || ''))
      .digest('hex');
  }
}

class InFlightAuthChecks {
  constructor({
    maxEntries = defaultAuthCacheMaxEntries,
    secretDigest = crypto.randomBytes(32)
  } = {}) {
    this.maxEntries = Math.max(0, Number(maxEntries ?? defaultAuthCacheMaxEntries) || 0);
    this.secretDigest = Buffer.from(secretDigest);
    this.entries = new Map();
  }

  run(username, password, authenticate) {
    if (!this.enabled()) return authenticate();
    const key = this.key(username, password);
    const existing = this.entries.get(key);
    if (existing) return existing;
    const pending = Promise.resolve()
      .then(authenticate)
      .finally(() => {
        this.entries.delete(key);
      });
    this.entries.set(key, pending);
    while (this.entries.size > this.maxEntries) {
      const oldestKey = this.entries.keys().next().value;
      if (oldestKey === undefined || oldestKey === key) break;
      this.entries.delete(oldestKey);
    }
    return pending;
  }

  enabled() {
    return this.maxEntries > 0;
  }

  key(username, password) {
    return crypto
      .createHmac('sha256', this.secretDigest)
      .update(String(username || '').trim().toLowerCase())
      .update('\0')
      .update(String(password || ''))
      .digest('hex');
  }
}

function requestPathname(req) {
  try {
    return new URL(req.url || '/', 'http://mailhub.internal').pathname;
  } catch {
    return '';
  }
}

function requestContentType(req) {
  return String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase();
}

function parseContentLength(value) {
  if (value === undefined) return 0;
  const raw = String(value);
  if (!/^\d+$/.test(raw)) return null;
  const parsed = Number(raw);
  return Number.isSafeInteger(parsed) ? parsed : null;
}

async function readJson(req, limit) {
  const chunks = [];
  let bytes = 0;
  for await (const chunk of req) {
    bytes += chunk.length;
    if (bytes > limit) throw new RequestTooLargeError();
    chunks.push(chunk);
  }
  if (!chunks.length) throw new Error('Request body is required');
  const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
  if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Object body is required');
  return body;
}

function normalizeAuthRequest(body) {
  if (typeof body.username !== 'string' || typeof body.password !== 'string') return null;
  if (typeof body.remoteIp !== 'string') return null;
  const username = body.username.trim();
  const remoteIp = body.remoteIp.trim();
  const service = String(body.service || 'imap').trim().toLowerCase();
  if (!username || Buffer.byteLength(username, 'utf8') > 320 || /[\r\n\u0000]/.test(username)) return null;
  if (Buffer.byteLength(body.password, 'utf8') > defaultBodyLimit) return null;
  if (!isIP(remoteIp) || !['imap', 'pop3'].includes(service)) return null;
  return { username, password: body.password, remoteIp };
}

function canonicalMailboxAddress(authenticated) {
  const rawAddress = String(authenticated?.mailbox?.address || '');
  const address = rawAddress.toLowerCase();
  if (
    rawAddress !== rawAddress.trim()
    || Buffer.byteLength(address, 'utf8') > 320
    || /[\/\\\u0000\s]/.test(address)
    || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)
  ) return '';
  return address;
}

function setPrivateHeaders(res) {
  res.setHeader('Cache-Control', 'no-store');
  res.setHeader('Pragma', 'no-cache');
  res.setHeader('X-Content-Type-Options', 'nosniff');
}

function sendJson(res, status, payload, headers = {}) {
  if (res.writableEnded) return;
  const body = JSON.stringify(payload);
  res.writeHead(status, {
    'Content-Type': 'application/json; charset=utf-8',
    'Content-Length': String(Buffer.byteLength(body)),
    ...headers
  });
  res.end(body);
}

function sendTooLarge(req, res) {
  req.resume();
  return sendJson(res, 413, { error: 'Request too large.' }, { Connection: 'close' });
}

function positiveInteger(value, fallback) {
  const parsed = Number(value);
  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}

class RequestTooLargeError extends Error {}
