import amqp from "amqplib";
import { env } from "@/lib/env";
import { logError } from "@/lib/logger";

/**
 * Publishes member-facing mail onto the SMTP queue.
 *
 * The same contract the main core (karma-gateway) publishes on: one durable message
 * per mail, consumed by the shared SMTP worker, which is what actually talks to
 * SendGrid. This service does not send member mail itself.
 *
 * That indirection is not ceremony. `lib/mailer.ts` sends console-facing mail
 * (export approvals, password resets) straight over SMTP, and it is fine there
 * because those go to a handful of internal addresses and nobody is counting them.
 * Member mail is different: it is subject to the worker's retry, suppression and
 * unsubscribe handling, and sending it from here would bypass all of it.
 *
 * The connection is opened on demand and closed after an idle period rather than
 * held for the process's life — this publishes a few messages a day, and a
 * permanently open channel is a connection the broker has to keep for nothing.
 */

export interface MailRecipient {
  name?: string;
  email: string;
}

export class MailQueue {
  private readonly queueName = env.GetString("SMTP_QUEUE_NAME");
  private readonly connectionString = env.GetString(
    "PRODUCER_QUEUE_CONNECTION",
  );

  private connection: amqp.ChannelModel | null = null;
  private channel: amqp.ConfirmChannel | null = null;
  private closeTimer: NodeJS.Timeout | null = null;

  constructor(
    private fromEmail?: string,
    private replyTo?: MailRecipient[],
    private readonly idleTimeoutMs = 5000,
  ) { }

  private async getChannel(): Promise<amqp.ConfirmChannel> {
    if (!this.connection || !this.channel) {
      this.connection = await amqp.connect(this.connectionString);
      /*
       * A confirm channel, not a plain one.
       *
       * `sendToQueue` on a plain channel is fire-and-forget: it returns true when
       * the frame is buffered locally, which is not the broker having the message.
       * A publish that the broker rejected — or one lost when the connection
       * dropped a moment later — looked identical to a successful one, so "queued"
       * was a claim this code could not actually make. With confirms, the publish
       * is only reported once the broker has acknowledged it.
       */
      this.channel = await this.connection.createConfirmChannel();
      await this.channel.assertQueue(this.queueName, { durable: true });

      this.connection.once("error", () => this.forget());
      this.connection.once("close", () => this.forget());
    }
    return this.channel;
  }

  private forget(): void {
    this.channel = null;
    this.connection = null;
    if (this.closeTimer) {
      clearTimeout(this.closeTimer);
      this.closeTimer = null;
    }
  }

  private scheduleClose(): void {
    if (this.closeTimer) clearTimeout(this.closeTimer);
    this.closeTimer = setTimeout(() => void this.close(), this.idleTimeoutMs);
  }

  async close(): Promise<void> {
    try {
      await this.channel?.close();
      await this.connection?.close();
    } catch {
      // Already gone; `forget` below is the only thing that has to happen.
    } finally {
      this.forget();
    }
  }

  async publish(
    templateID: string,
    mail: {
      to: MailRecipient[];
      subject?: string;
      templateData?: Record<string, unknown>;
      cc?: MailRecipient[];
      bcc?: MailRecipient[];
    },
  ): Promise<void> {
    try {
      const channel = await this.getChannel();

      // The mailbox the main core copies on every member mail, kept here so the
      // archive stays complete. Dropped when it is already a direct recipient,
      // which SendGrid would otherwise treat as a duplicate delivery.
      const cc = [
        ...(mail.cc ?? []),
        { email: "kgmailer@karmagroup.com" },
      ].filter(
        (recipient) => !mail.to.some((target) => target.email === recipient.email),
      );

      await new Promise<void>((resolve, reject) => {
        channel.sendToQueue(
          this.queueName,
          Buffer.from(
            JSON.stringify({
              fromEmail: this.fromEmail,
              templateID,
              to: mail.to,
              subject: mail.subject,
              templateData: mail.templateData,
              cc,
              bcc: mail.bcc,
              /*
               * `replyTo`, which is what the worker's schema actually names.
               *
               * The main core publishes this as `replyToList`, and the worker parses
               * with a zod object that strips unknown keys — so that field has always
               * been dropped rather than rejected, and reply-to has never taken
               * effect. Named correctly here.
               */
              replyTo: this.replyTo,
            }),
          ),
          { persistent: true },
          (err) => (err ? reject(err) : resolve()),
        );
      });

      this.scheduleClose();
    } catch (err) {
      this.forget();
      logError(err, "[MailQueue] Failed to publish mail");
      throw err instanceof Error ? err : new Error(String(err));
    }
  }
}

let shared: MailQueue | null = null;

/** One publisher per process; the connection inside it is still opened on demand. */
export const getMailQueue = (): MailQueue => {
  if (!shared) {
    shared = new MailQueue(env.GetString("SMTP_FROM"));
  }
  return shared;
};
