import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { CachedEmail } from '../db/index.js';
import type {
  Email,
  IExtendedSmtpSession,
  IMessageAcceptanceContext,
  IMessageAcceptanceDecision,
  UnifiedEmailServer,
} from '@push.rocks/smartmta';
import type { DcRouter } from '../classes.dcrouter.js';

export const DCROUTER_CACHE_ID_HEADER = 'X-Dcrouter-Cached-Email-Id';
const ACCEPTED_EMAIL_SPOOL_INTERVAL_MS = 60_000;
const ACCEPTED_EMAIL_RETRY_DELAY_MS = 5 * 60_000;
const ACCEPTED_EMAIL_QUEUE_LEASE_MS = 30 * 60_000;
const ACCEPTED_EMAIL_SPOOL_BATCH_SIZE = 25;
const ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS = 30_000;

export type TSmartMtaQueueItemLike = {
  processingResult?: {
    headers?: Record<string, string>;
    email?: { headers?: Record<string, string> };
  };
  status?: 'pending' | 'processing' | 'queued' | 'delivered' | 'failed' | 'deferred';
  attempts?: number;
  nextAttempt?: Date;
  lastError?: string;
};

type TStoredCachedEmailEnvelopeAddress = {
  address: string;
  args?: Record<string, string>;
};

type TStoredCachedEmailSession = {
  id?: string;
  clientHostname?: string;
  remoteAddress?: string;
  secure?: boolean;
  authenticated?: boolean;
  user?: IExtendedSmtpSession['user'];
  envelope?: {
    mailFrom?: TStoredCachedEmailEnvelopeAddress;
    rcptTo?: TStoredCachedEmailEnvelopeAddress[];
  };
};

type TStoredCachedEmailRouteData = {
  session?: TStoredCachedEmailSession;
};

/**
 * Accept-then-spool pipeline for inbound SMTP messages: persists accepted
 * messages as CachedEmail docs, replays them through SmartMTA on an interval,
 * and mirrors SmartMTA delivery-queue outcomes back onto the cached docs.
 */
export class AcceptedEmailSpool {
  private spoolTimer?: ReturnType<typeof setInterval> & { unref?: () => void };
  private spoolRun?: Promise<void>;
  private processing = false;
  private stopping = false;
  private queueUpdatePromises = new Set<Promise<void>>();

  constructor(private dcRouterRef: DcRouter) {}

  public async acceptMessage(
    context: IMessageAcceptanceContext,
    processAfterAccept = true,
  ): Promise<IMessageAcceptanceDecision> {
    if (!this.dcRouterRef.dcRouterDb?.isReady()) {
      throw new Error('DcRouterDb is not available for email acceptance');
    }
    this.throwIfMessageAcceptanceAborted(context.abortSignal);

    const rawMessage = context.rawMessage;
    const session = context.session;
    const envelope = session.envelope;
    const envelopeRecipients = Array.isArray(envelope.rcptTo)
      ? envelope.rcptTo.map((recipient) => recipient.address).filter(Boolean)
      : [];
    const email = context.email;
    const headers = email.headers;

    const cachedEmail = CachedEmail.createNew();
    this.removeHeader(email.headers, DCROUTER_CACHE_ID_HEADER);
    email.headers[DCROUTER_CACHE_ID_HEADER] = cachedEmail.id;
    cachedEmail.messageId = headers['Message-ID'] || headers['message-id'] || cachedEmail.id;
    cachedEmail.from = envelope.mailFrom?.address || email.from || '';
    cachedEmail.to = envelopeRecipients.length > 0
      ? envelopeRecipients
      : Array.isArray(email.to) ? email.to : [];
    cachedEmail.cc = Array.isArray(email.cc) ? email.cc : [];
    cachedEmail.bcc = Array.isArray(email.bcc) ? email.bcc : [];
    cachedEmail.subject = email.subject || '';
    cachedEmail.rawContent = this.setDcRouterCacheIdHeader(rawMessage.toString('utf8'), cachedEmail.id);
    cachedEmail.status = 'pending';
    cachedEmail.nextAttempt = new Date();
    cachedEmail.routeData = JSON.stringify({
      acceptedAt: new Date().toISOString(),
      session: {
        id: session.id,
        remoteAddress: session.remoteAddress,
        clientHostname: session.clientHostname,
        secure: !!session.secure,
        authenticated: !!session.authenticated,
        user: session.user,
        envelope,
      },
    });
    cachedEmail.updateSenderDomain();
    await cachedEmail.save();
    if (context.abortSignal?.aborted) {
      cachedEmail.markFailed('Message acceptance aborted before SMTP success');
      await cachedEmail.save();
      throw new Error('Message acceptance aborted before SMTP success');
    }

    if (processAfterAccept) {
      this.run();
    } else {
      cachedEmail.markDelivered();
      await cachedEmail.save();
    }

    return {
      accepted: true,
      smtpCode: 250,
      smtpMessage: '2.0.0 Message accepted for delivery',
      continueProcessing: false,
    };
  }

  /** Start the interval-driven spool processor and trigger an immediate run. */
  public start(): void {
    this.clearSpoolTimer();
    this.stopping = false;
    const runProcessor = () => {
      this.run();
    };
    this.spoolTimer = setInterval(
      runProcessor,
      ACCEPTED_EMAIL_SPOOL_INTERVAL_MS,
    ) as ReturnType<typeof setInterval> & { unref?: () => void };
    this.spoolTimer.unref?.();
    runProcessor();
  }

  /** Mark the spool as stopping and clear the interval without awaiting in-flight work. */
  public beginStop(): void {
    this.stopping = true;
    this.clearSpoolTimer();
  }

  /** Stop the spool and wait (bounded) for an in-flight run to settle. */
  public async stop(): Promise<void> {
    this.beginStop();
    const spoolRun = this.spoolRun;
    if (spoolRun) {
      const settled = await this.waitForPromiseToSettleWithTimeout(
        spoolRun,
        ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS,
      );
      if (!settled) {
        logger.log('warn', 'Timed out waiting for accepted email spool processing to stop');
      }
    }
  }

  /** Kick off a spool run unless one is already in flight. */
  public run(): void {
    if (this.spoolRun) {
      return;
    }
    const run = this.processSpool().catch((error) => {
      logger.log('warn', `Accepted email spool processing failed: ${(error as Error).message}`);
    });
    this.spoolRun = run;
    void run.finally(() => {
      if (this.spoolRun === run) {
        this.spoolRun = undefined;
      }
    });
  }

  public trackQueueUpdate(
    item: TSmartMtaQueueItemLike,
    status: 'queued' | 'delivered' | 'failed',
    failureMessage: string,
  ): void {
    const updatePromise = this.updateAcceptedEmailFromQueueItem(item, status).catch((error) => {
      logger.log('warn', `${failureMessage}: ${(error as Error).message}`);
    });
    this.queueUpdatePromises.add(updatePromise);
    void updatePromise.finally(() => {
      this.queueUpdatePromises.delete(updatePromise);
    });
  }

  public async drainQueueUpdates(): Promise<void> {
    const queueUpdates = [...this.queueUpdatePromises];
    if (queueUpdates.length === 0) {
      return;
    }
    const settled = await this.waitForPromiseToSettleWithTimeout(
      Promise.allSettled(queueUpdates).then(() => undefined),
      ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS,
    );
    if (!settled) {
      for (const queueUpdate of queueUpdates) {
        this.queueUpdatePromises.delete(queueUpdate);
      }
      logger.log('warn', `Timed out waiting for ${queueUpdates.length} accepted email queue update(s) to settle`);
    }
  }

  /** Requeue emails left in 'queued' state by a previous process as pending. */
  public async recoverQueuedEmails(): Promise<void> {
    while (true) {
      const queuedEmails = await CachedEmail.findQueuedForRecovery(ACCEPTED_EMAIL_SPOOL_BATCH_SIZE);
      if (queuedEmails.length === 0) {
        return;
      }
      for (const queuedEmail of queuedEmails) {
        if (this.isCachedEmailTerminal(queuedEmail)) {
          continue;
        }
        queuedEmail.status = 'pending';
        queuedEmail.nextAttempt = new Date();
        await queuedEmail.save();
      }
      if (queuedEmails.length < ACCEPTED_EMAIL_SPOOL_BATCH_SIZE) {
        return;
      }
    }
  }

  private async processSpool(): Promise<void> {
    const emailServer = this.dcRouterRef.emailServer;
    if (this.processing || !emailServer || !this.dcRouterRef.dcRouterDb?.isReady()) {
      return;
    }
    this.processing = true;
    try {
      const cachedEmails = await CachedEmail.findPendingForDelivery(ACCEPTED_EMAIL_SPOOL_BATCH_SIZE);
      for (const cachedEmail of cachedEmails) {
        if (this.stopping || this.dcRouterRef.emailServer !== emailServer) {
          break;
        }
        const session = this.buildCachedEmailSession(cachedEmail);
        const rawMessage = plugins.buffer.Buffer.from(cachedEmail.rawContent || '', 'utf8');
        await this.processAcceptedCachedEmail(cachedEmail, rawMessage, session, emailServer);
      }
    } finally {
      this.processing = false;
    }
  }

  private async processAcceptedCachedEmail(
    cachedEmail: CachedEmail,
    emailData: Email | plugins.buffer.Buffer,
    session: IExtendedSmtpSession,
    emailServer: UnifiedEmailServer,
  ): Promise<void> {
    cachedEmail.status = 'processing';
    cachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
    await cachedEmail.save();

    try {
      await emailServer.processEmailByMode(emailData, session);
      if (session.matchedRoute?.action.type === 'forward') {
        cachedEmail.markDelivered();
      } else {
        const currentCachedEmail = await CachedEmail.findById(cachedEmail.id) || cachedEmail;
        if (this.isCachedEmailTerminal(currentCachedEmail)) {
          return;
        }
        currentCachedEmail.status = 'queued';
        currentCachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
        await currentCachedEmail.save();
        return;
      }
      await cachedEmail.save();
    } catch (error: unknown) {
      cachedEmail.scheduleRetry(ACCEPTED_EMAIL_RETRY_DELAY_MS);
      cachedEmail.lastError = (error as Error).message;
      await cachedEmail.save();
      logger.log('warn', `Accepted email ${cachedEmail.id} deferred after SmartMTA handoff failure: ${(error as Error).message}`);
    }
  }

  private buildCachedEmailSession(cachedEmail: CachedEmail): IExtendedSmtpSession {
    const routeData = this.parseCachedEmailRouteData(cachedEmail);
    const storedSession = routeData.session || {};
    const storedEnvelope = storedSession.envelope || {};
    const storedRcptTo = Array.isArray(storedEnvelope.rcptTo) && storedEnvelope.rcptTo.length > 0
      ? storedEnvelope.rcptTo
      : cachedEmail.to.map((address) => ({ address, args: {} }));
    const rcptTo = storedRcptTo
      .filter((recipient) => !!recipient.address)
      .map((recipient) => ({ address: recipient.address, args: recipient.args || {} }));
    const mailFrom = storedEnvelope.mailFrom
      ? { address: storedEnvelope.mailFrom.address, args: storedEnvelope.mailFrom.args || {} }
      : { address: cachedEmail.from || '', args: {} };
    const session = {
      id: `${storedSession.id || cachedEmail.id}-replay-${Date.now()}`,
      state: 'DATA' as unknown as IExtendedSmtpSession['state'],
      clientHostname: storedSession.clientHostname || '',
      mailFrom: mailFrom.address,
      rcptTo: rcptTo.map((recipient) => recipient.address),
      emailData: cachedEmail.rawContent || '',
      useTLS: !!storedSession.secure,
      connectionEnded: false,
      remoteAddress: storedSession.remoteAddress || '127.0.0.1',
      secure: !!storedSession.secure,
      authenticated: !!storedSession.authenticated,
      envelope: {
        mailFrom,
        rcptTo,
      },
    } as IExtendedSmtpSession;
    if (storedSession.user) {
      session.user = storedSession.user;
    }
    return session;
  }

  private parseCachedEmailRouteData(cachedEmail: CachedEmail): TStoredCachedEmailRouteData {
    try {
      return cachedEmail.routeData ? JSON.parse(cachedEmail.routeData) : {};
    } catch {
      return {};
    }
  }

  private async updateAcceptedEmailFromQueueItem(
    item: TSmartMtaQueueItemLike,
    status: 'queued' | 'delivered' | 'failed',
  ): Promise<void> {
    const cachedEmailId = this.getCachedEmailIdFromQueueItem(item);
    if (!cachedEmailId || !this.dcRouterRef.dcRouterDb?.isReady()) {
      return;
    }
    const cachedEmail = await CachedEmail.findById(cachedEmailId);
    if (!cachedEmail) {
      return;
    }
    if (this.isCachedEmailTerminal(cachedEmail) && status !== 'delivered') {
      return;
    }
    cachedEmail.attempts = Math.max(cachedEmail.attempts || 0, item.attempts || 0);
    if (status === 'delivered') {
      cachedEmail.markDelivered();
    } else if (status === 'failed') {
      cachedEmail.markFailed(item.lastError || 'SmartMTA delivery failed');
    } else {
      cachedEmail.status = 'queued';
      cachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
    }
    await cachedEmail.save();
  }

  private async waitForPromiseToSettleWithTimeout(
    promise: Promise<unknown>,
    timeoutMs: number,
  ): Promise<boolean> {
    let timeout: (ReturnType<typeof setTimeout> & { unref?: () => void }) | undefined;
    return await new Promise<boolean>((resolve) => {
      let settled = false;
      const settle = (didSettle: boolean) => {
        if (settled) {
          return;
        }
        settled = true;
        if (timeout) {
          clearTimeout(timeout);
        }
        resolve(didSettle);
      };
      timeout = setTimeout(() => settle(false), timeoutMs) as ReturnType<typeof setTimeout> & { unref?: () => void };
      timeout.unref?.();
      promise.then(
        () => settle(true),
        () => settle(true),
      );
    });
  }

  private clearSpoolTimer(): void {
    if (this.spoolTimer) {
      clearInterval(this.spoolTimer);
      this.spoolTimer = undefined;
    }
  }

  private setDcRouterCacheIdHeader(rawContent: string, cachedEmailId: string): string {
    const headerRegex = new RegExp(`^${DCROUTER_CACHE_ID_HEADER}:.*(?:\r?\n[\t ].*)*\r?\n?`, 'gim');
    const sanitizedContent = rawContent.replace(headerRegex, '');
    return `${DCROUTER_CACHE_ID_HEADER}: ${cachedEmailId}\r\n${sanitizedContent}`;
  }

  private isCachedEmailTerminal(cachedEmail: CachedEmail): boolean {
    return cachedEmail.status === 'delivered' || cachedEmail.status === 'failed';
  }

  private getCachedEmailIdFromQueueItem(item: TSmartMtaQueueItemLike): string | undefined {
    return this.getHeaderValue(item.processingResult?.headers, DCROUTER_CACHE_ID_HEADER)
      || this.getHeaderValue(item.processingResult?.email?.headers, DCROUTER_CACHE_ID_HEADER);
  }

  private getHeaderValue(headers: Record<string, string> | undefined, headerName: string): string | undefined {
    if (!headers) {
      return undefined;
    }
    const normalizedHeaderName = headerName.toLowerCase();
    const matchingHeaderName = Object.keys(headers).find((key) => key.toLowerCase() === normalizedHeaderName);
    return matchingHeaderName ? headers[matchingHeaderName] : undefined;
  }

  private removeHeader(headers: Record<string, string>, headerName: string): void {
    const normalizedHeaderName = headerName.toLowerCase();
    for (const key of Object.keys(headers)) {
      if (key.toLowerCase() === normalizedHeaderName) {
        delete headers[key];
      }
    }
  }

  private throwIfMessageAcceptanceAborted(abortSignal: AbortSignal | undefined): void {
    if (abortSignal?.aborted) {
      throw new Error('Message acceptance aborted before SMTP success');
    }
  }
}
