import {
  Injectable,
  Logger,
  OnModuleInit,
  ServiceUnavailableException,
} from "@nestjs/common";
import {
  Currency,
  EmailNotificationServicio,
  EmailNotificationStatus,
  EmailNotificationType,
  Prisma,
  TransactionDomain,
} from "@jorgecuadros/database";
import { MailService } from "../mail/mail.service";
import { PrismaService } from "../prisma/prisma.service";
import { SettingsService } from "../settings/settings.service";
import { NotificationLogService } from "./notification-log.service";
import { NotificationScheduleService } from "./notification-schedule.service";
import {
  DEBUG_RECIPIENT,
  SendAttempt,
  NotificationJobKind,
  NotificationJobResponse,
  NotificationRunAllJobResult,
  NotificationRunAllResponse,
  AttemptStatus,
} from "./notification.types";
import {
  renderOutstanding,
  renderPaymentConfirm,
  renderAccountStatus,
  renderTrustConfirm,
} from "./render";

/**
 * Mass email notifications — the modern replacement for the four PHP
 * scripts under `email.notifications/send*.php`. One service, four job
 * methods, identical wire shape to the legacy scripts (so a log scraper
 * parsing the JSON response keeps working — see
 * `~/Documents/Claude-Memory/email-notifications-spec.md`).
 *
 * ── Recipient selection ────────────────────────────────────────────────────
 *  Job 1 (Outstanding):   customers with at least one Transaction where
 *                         `outstanding = true` and the movement is a charge
 *                         (amount < 0). Equivalent to legacy
 *                         `datosfreak WHERE NOPAGO = 1`.
 *  Job 2 (PaymentConf):   customers with a credit (amount > 0) posted in
 *                         the last 24 hours. Equivalent to legacy
 *                         `pagosemail` view, which the ETL refreshed daily
 *                         from the same predicate.
 *  Job 3 (AccountStatus): every customer with a non-null email; per row
 *                         the balance = SUM(transactions.amount) per
 *                         currency, excluding voided + outstanding — same
 *                         arithmetic `BillingService.balances()` uses, so
 *                         the yellow/red alert lines up with what the
 *                         receivables worklist already shows staff.
 *  Job 4 (TrustConfirm):  customers with a `TrustAccount` whose email is
 *                         set, where the latest trust-domain credit was
 *                         posted in the last 24 hours. The trust-fee ETL
 *                         used to push one row per annual fee payment.
 *
 * ── Audit log ──────────────────────────────────────────────────────────────
 * Every send attempt (sent, failed, or skipped) writes one row to
 * `email_notification_log`. Job 3 additionally writes one row per
 * threshold hit to `account_status_history`, mirroring the legacy
 * `utility_dbo.send_account_status_history` table verbatim.
 *
 * ── Day-of-week gates (Job 3) ──────────────────────────────────────────────
 *  Yellow: Wed only (or `ignoreDayRestriction`).
 *  Red:    Mon/Wed/Fri only (or `ignoreDayRestriction`).
 * A customer who is red on a Tuesday is skipped (SKIPPED_GATE) until Wed,
 * when both checks can run on the same row — keeps the on-demand behavior
 * in lock-step with the legacy script.
 */

const ONE_HOUR_MS = 60 * 60 * 1000;
const RATE_LIMIT_EMAILS = 100;
const PAYMENT_LOOKBACK_HOURS = 24;

@Injectable()
export class NotificationsService implements OnModuleInit {
  private readonly logger = new Logger(NotificationsService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly mail: MailService,
    private readonly log: NotificationLogService,
    private readonly settings: SettingsService,
    private readonly schedule: NotificationScheduleService,
  ) {}

  /** The automatic servicios sweep is the same "ejecutar todos" the button
   *  fires. Off by default — see the defaults in `NotificationScheduleService`. */
  async onModuleInit(): Promise<void> {
    await this.schedule.register("servicios", () => this.scheduledRunAll());
  }

  /**
   * Unattended run of all four jobs. Never debug, and never
   * `ignoreDayRestriction`: an automatic run on the operator's own cadence is
   * exactly the case the Mon/Wed/Fri gate was written for, so bypassing it
   * here would mail the red list every single scheduled day.
   */
  async scheduledRunAll(): Promise<void> {
    const result = await this.runAll({});
    this.logger.log(
      `Corrida programada de servicios: enviados ${result.sent}, ` +
        `omitidos ${result.skipped}, fallidos ${result.failed}, ` +
        `jobs con error ${result.errors}.`,
    );
  }

  /* ============================================================================
   *  Public jobs — called by the controller, the schedule, and tests alike.
   * ========================================================================== */

  /** Job 1 — Outstanding payments. */
  async runOutstandingPayments(flags: {
    debug?: boolean;
    ignoreDayRestriction?: boolean;
    useEmailLimit?: boolean;
  }): Promise<NotificationJobResponse> {
    const debug = !!flags.debug;
    const candidates = await this.prisma.customer.findMany({
      where: {
        email: { not: null },
        archivedAt: null,
        transactions: {
          some: {
            outstanding: true,
            amount: { lt: 0 },
            voidedAt: null,
          },
        },
      },
      select: {
        id: true,
        name: true,
        email: true,
        transactions: {
          where: { outstanding: true, amount: { lt: 0 }, voidedAt: null },
          orderBy: { transactionDate: "asc" },
          select: {
            id: true,
            transactionDate: true,
            reference: true,
            period: true,
            amount: true,
            type: { select: { nameEn: true } },
          },
        },
      },
      orderBy: { id: "asc" },
    });

    let sent = 0;
    let skipped = 0;
    let failed = 0;

    for (const c of candidates) {
      const email = debug ? this.debugEmail() : (c.email ?? "").toLowerCase();
      if (!email || !email.includes("@")) {
        await this.recordAttempt({
          notificationType: "OUTSTANDING_PAYMENT",
          servicio: "CUSTOMERS",
          customerId: c.id,
          customerName: c.name,
          customerEmail: email || "(missing)",
          subject: "Jorge Cuadros - Outstanding Payments",
          bodySnapshot: "(skipped: no email)",
          status: "SKIPPED_NO_EMAIL",
          debug,
        });
        skipped++;
        continue;
      }

      const total = c.transactions.reduce(
        (acc, t) => acc.plus(t.amount),
        new Prisma.Decimal(0),
      );
      let running = new Prisma.Decimal(0);
      const rows = c.transactions.map((t) => {
        running = running.plus(t.amount);
        return {
          date: t.transactionDate.toISOString().slice(0, 10),
          reference: t.reference,
          period: t.period,
          type: t.type?.nameEn ?? null,
          amount: t.amount.toFixed(2),
          balance: running.toFixed(2),
        };
      });

      const body = renderOutstanding({
        customerId: c.id,
        customerName: c.name,
        total: total.abs().toFixed(2),
        rows,
        year: new Date().getFullYear(),
      });

      const attempt = await this.deliver({
        notificationType: "OUTSTANDING_PAYMENT",
        servicio: "CUSTOMERS",
        customerId: c.id,
        customerName: c.name,
        customerEmail: email,
        subject: "Jorge Cuadros - Outstanding Payments",
        bodySnapshot: body,
        debug,
      });
      if (attempt === "SENT") sent++;
      else if (attempt === "SKIPPED_NO_EMAIL" || attempt === "SKIPPED_GATE") skipped++;
      else failed++;
    }

    const response: NotificationJobResponse = {
      result: "success",
      notificationType: "sendPaymentConfirmation",
      reason: `sent confirmation ${sent} emails`,
      statusCode: 200,
      sent,
      skipped,
      failed,
      debug,
      type: "OUTSTANDING_PAYMENT",
    };
    await this.adminSummary("Jorge Cuadros - Outstanding Payments", response);
    return response;
  }

  /** Job 2 — Payment confirmation. One email per customer whose latest
   *  credit (positive amount Transaction) landed in the last 24h. */
  async runPaymentConfirmation(flags: {
    debug?: boolean;
    ignoreDayRestriction?: boolean;
    useEmailLimit?: boolean;
  }): Promise<NotificationJobResponse> {
    const debug = !!flags.debug;
    const since = new Date(Date.now() - PAYMENT_LOOKBACK_HOURS * 3600_000);

    // Find customers with a credit in the window. We then pick the most
    // recent credit per customer; if multiple, we send one summary per
    // customer (the PHP script also sent one per customer, picking the
    // row the `pagosemail` view exposed for that NUMid).
    const credits = await this.prisma.transaction.findMany({
      where: {
        amount: { gt: 0 },
        voidedAt: null,
        transactionDate: { gte: since },
        customer: { archivedAt: null },
      },
      orderBy: { transactionDate: "desc" },
      select: {
        id: true,
        transactionDate: true,
        reference: true,
        amount: true,
        currency: true,
        type: { select: { nameEn: true, nameEs: true } },
        customer: {
          select: { id: true, name: true, email: true, preferredCurrency: true },
        },
      },
    });

    // One row per customer (most recent credit wins).
    const byCustomer = new Map<
      string,
      (typeof credits)[number]
    >();
    for (const t of credits) {
      if (!byCustomer.has(t.customer.id)) byCustomer.set(t.customer.id, t);
    }
    const total = byCustomer.size;

    let sent = 0;
    let skipped = 0;
    let failed = 0;

    for (const [, t] of byCustomer) {
      const email = debug
        ? this.debugEmail()
        : (t.customer.email ?? "").toLowerCase();
      if (!email || !email.includes("@")) {
        await this.recordAttempt({
          notificationType: "PAYMENT_CONFIRMATION",
          servicio: "CUSTOMERS",
          customerId: t.customer.id,
          customerName: t.customer.name,
          customerEmail: email || "(missing)",
          subject: "Jorge Cuadros - Payment Confirmation",
          bodySnapshot: "(skipped: no email)",
          status: "SKIPPED_NO_EMAIL",
          debug,
        });
        skipped++;
        continue;
      }

      const typeOfTrx = t.type?.nameEn ?? t.type?.nameEs ?? "PAYMENT";
      const body = renderPaymentConfirm({
        customerId: t.customer.id,
        customerName: t.customer.name,
        typeOfTrx,
        reference: t.reference,
        amount: t.amount.toFixed(2),
        year: new Date().getFullYear(),
      });

      // The PHP script stored the per-customer URL on the log row verbatim;
      // we preserve the convention with a synthetic URL string. This is the
      // single piece of legacy data the new log carries that does not come
      // from a real fetch — a tag, not a request.
      const bodyRequestUrl = `payment-confirmation://${t.customer.id}/${encodeURIComponent(
        typeOfTrx,
      )}/${t.id}`;

      const attempt = await this.deliver({
        notificationType: "PAYMENT_CONFIRMATION",
        servicio: "CUSTOMERS",
        customerId: t.customer.id,
        customerName: t.customer.name,
        customerEmail: email,
        subject: "Jorge Cuadros - Payment Confirmation",
        bodySnapshot: body,
        bodyRequestUrl,
        debug,
      });
      if (attempt === "SENT") sent++;
      else if (attempt === "SKIPPED_NO_EMAIL" || attempt === "SKIPPED_GATE") skipped++;
      else failed++;
    }

    const response: NotificationJobResponse = {
      request: "success",
      notificationType: "sendPaymentConfirmation",
      confirmationSent: `${sent} of ${total}`,
      statusCode: 200,
      sent,
      skipped,
      failed,
      debug,
      type: "PAYMENT_CONFIRMATION",
    };
    await this.adminSummary("Jorge Cuadros - Payment Confirmations Sent", response);
    return response;
  }

  /** Job 3 — Account status. Day gates + per-currency balance + dual
   *  threshold (yellow/red). The most complex of the four jobs. */
  async runAccountStatus(flags: {
    debug?: boolean;
    ignoreDayRestriction?: boolean;
    useEmailLimit?: boolean;
  }): Promise<NotificationJobResponse> {
    const debug = !!flags.debug;
    const ignoreDayRestriction = !!flags.ignoreDayRestriction;
    const useEmailLimit = !!flags.useEmailLimit;

    const today = new Date()
      .toLocaleString("en-US", { weekday: "short", timeZone: "America/Tijuana" })
      .slice(0, 3) as "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun";
    const redDay = today === "Mon" || today === "Wed" || today === "Fri";
    const yellowDay = today === "Wed";
    const gateOpen = ignoreDayRestriction || redDay || yellowDay;

    // Pull every customer with a non-empty email and at least one movement
    // that contributes to the balance (voided + outstanding excluded, same
    // as `BillingService.balances()`).
    const rows = await this.prisma.$queryRaw<
      {
        id: string;
        name: string;
        email: string;
        balanceMxn: Prisma.Decimal | null;
        balanceUsd: Prisma.Decimal | null;
      }[]
    >`
      SELECT
        c.id,
        c.name,
        c.email,
        SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END) AS balanceMxn,
        SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END) AS balanceUsd
      FROM customers c
      JOIN transactions t ON t.customerId = c.id
      WHERE c.archivedAt IS NULL
        AND c.email IS NOT NULL AND c.email <> ''
        AND t.voidedAt IS NULL AND t.outstanding = 0
      GROUP BY c.id, c.name, c.email
    `;

    const total = rows.length;
    let red = 0;
    let yellow = 0;
    let sent = 0;
    let failed = 0;
    let skipped = 0;
    let emailsThisRun = 0;

    for (const row of rows) {
      // Skip customers with no email — should never happen because of the
      // WHERE clause, but defends against a row that gets archived between
      // the SQL and the loop.
      const rawEmail = row.email ?? "";
      const email = debug ? this.debugEmail() : rawEmail.toLowerCase();
      if (!email || !email.includes("@")) {
        await this.recordAttempt({
          notificationType: "ACCOUNT_STATUS",
          servicio: "CUSTOMERS",
          customerId: row.id,
          customerName: row.name,
          customerEmail: rawEmail || "(missing)",
          subject: "Jorge Cuadros - Account Status Alert",
          bodySnapshot: "(skipped: no email)",
          status: "SKIPPED_NO_EMAIL",
          debug,
        });
        skipped++;
        continue;
      }

      // The unified ledger is per-currency and never collapsed. The legacy
      // `TIPO` mapped 1:1 to USD (TIPO=50 / 100 / 200 / 300 / 500 were all
      // USD thresholds with TIPO/TIPODECAMBIO implied). The simplest port
      // is: convert every customer's MXN balance to USD using today's
      // effective rate and apply the USD thresholds; a customer whose
      // balance is genuinely USD-denominated uses balanceUsd directly.
      // For the office's actual data this is "report in USD", which is
      // what `Customer.minimumBalance` was set up for.
      const mxn = row.balanceMxn ? Number(row.balanceMxn.toString()) : 0;
      const usd = row.balanceUsd ? Number(row.balanceUsd.toString()) : 0;
      // Pick the currency that holds the bulk of the debt: USD preferred
      // because the legacy letter was always USD.
      const balance = usd !== 0 ? usd : mxn;

      // Apply both thresholds (yellow + red). The PHP script sent both
      // on a Wed: yellow + red, two emails, two log rows. We match that.
      // Customer.minimumBalance is the new-schema replacement for TIPO.
      const minBalance = await this.customerMinimum(row.id, balance);

      // RED first: a negative balance is always red. The yellow check
      // requires balance >= 0, so the two never co-fire on the same row.
      let firedRed = false;
      let firedYellow = false;

      if (balance < 0) {
        if (!redDay && !ignoreDayRestriction) {
          await this.recordAttempt({
            notificationType: "ACCOUNT_STATUS",
            level: 1,
            servicio: "CUSTOMERS",
            customerId: row.id,
            customerName: row.name,
            customerEmail: email,
            subject: "Jorge Cuadros - Account Status Alert",
            bodySnapshot: "(skipped: red day gate)",
            status: "SKIPPED_GATE",
            debug,
          });
          skipped++;
        } else {
          const ok = await this.sendAccountStatusAlert({
            customerId: row.id,
            customerName: row.name,
            customerEmail: email,
            balance,
            level: 1,
            tipo: balance, // red: "rush this much USD"
            minBalance,
            debug,
          });
          if (ok) {
            red++;
            sent++;
            firedRed = true;
          } else {
            failed++;
          }
        }
      }

      if (balance >= 0 && minBalance !== null && balance < minBalance) {
        if (!yellowDay && !ignoreDayRestriction) {
          await this.recordAttempt({
            notificationType: "ACCOUNT_STATUS",
            level: 0,
            servicio: "CUSTOMERS",
            customerId: row.id,
            customerName: row.name,
            customerEmail: email,
            subject: "Jorge Cuadros - Account Status Alert",
            bodySnapshot: "(skipped: yellow day gate)",
            status: "SKIPPED_GATE",
            debug,
          });
          skipped++;
        } else {
          const ok = await this.sendAccountStatusAlert({
            customerId: row.id,
            customerName: row.name,
            customerEmail: email,
            balance,
            level: 0,
            tipo: minBalance - balance, // yellow: "top up to minimum"
            minBalance,
            debug,
          });
          if (ok) {
            yellow++;
            sent++;
            firedYellow = true;
          } else {
            failed++;
          }
        }
      }

      if (!firedRed && !firedYellow) continue;

      // Vestigial SMTP-era throttle (preserved for parity). Off by default;
      // the controller wires useEmailLimit=true only when staff opt in.
      if (useEmailLimit) {
        emailsThisRun += firedRed ? 1 : 0;
        emailsThisRun += firedYellow ? 1 : 0;
        if (emailsThisRun >= RATE_LIMIT_EMAILS) {
          this.logger.warn("SMTP email send limit reached, sleeping 1 hour.");
          await new Promise((r) => setTimeout(r, ONE_HOUR_MS));
          emailsThisRun = 0;
          this.logger.warn("Resuming send account status emails!");
        }
      }
    }

    const response: NotificationJobResponse = {
      request: "success",
      notificationType: "sendAccountStatus",
      statusSent: `Sent ${red} Red Emails and ${yellow} Yellow Emails of ${total} customers.`,
      statusReport: "https://cpanel.jorgecuadros.com/show_email_log.php",
      statusCode: 200,
      red,
      yellow,
      total,
      sent,
      skipped,
      failed,
      debug,
      type: "ACCOUNT_STATUS",
    };
    await this.adminSummary("Jorge Cuadros - Account Status Alerts Sent", response);
    return response;
  }

  /** Job 4 — Trust payment confirmation. */
  async runTrustConfirmation(flags: {
    debug?: boolean;
    ignoreDayRestriction?: boolean;
    useEmailLimit?: boolean;
  }): Promise<NotificationJobResponse> {
    const debug = !!flags.debug;
    const since = new Date(Date.now() - PAYMENT_LOOKBACK_HOURS * 3600_000);

    // Latest credit per TrustAccount-bearing customer in the lookback
    // window. The PHP script pulled from `TRUSTHFEE` directly; the
    // equivalent here is: customer owns a property with a trust account
    // AND has a credit in the trust domain in the last 24h.
    const credits = await this.prisma.transaction.findMany({
      where: {
        amount: { gt: 0 },
        domain: TransactionDomain.TRUST,
        voidedAt: null,
        transactionDate: { gte: since },
        customer: { archivedAt: null },
      },
      orderBy: { transactionDate: "desc" },
      select: {
        id: true,
        amount: true,
        currency: true,
        customer: {
          select: {
            id: true,
            name: true,
            email: true,
            properties: {
              where: { archivedAt: null },
              select: {
                trustAccount: { select: { id: true, trustNumber: true } },
              },
            },
          },
        },
      },
    });

    // One row per customer (most recent credit wins). Drop customers with
    // no trust account — they have nothing to confirm against.
    const byCustomer = new Map<
      string,
      (typeof credits)[number]
    >();
    for (const t of credits) {
      if (!t.customer.properties.some((p) => p.trustAccount)) continue;
      if (!byCustomer.has(t.customer.id)) byCustomer.set(t.customer.id, t);
    }
    const total = byCustomer.size;

    let sent = 0;
    let skipped = 0;
    let failed = 0;

    for (const [, t] of byCustomer) {
      const email = debug
        ? this.debugEmail()
        : (t.customer.email ?? "").toLowerCase();
      if (!email || !email.includes("@")) {
        await this.recordAttempt({
          notificationType: "TRUST_PAYMENT_CONFIRMATION",
          servicio: "TRUST",
          customerId: t.customer.id,
          customerName: t.customer.name,
          customerEmail: email || "(missing)",
          subject: "Jorge Cuadros - Trust Payment Confirmation",
          bodySnapshot: "(skipped: no email)",
          status: "SKIPPED_NO_EMAIL",
          debug,
        });
        skipped++;
        continue;
      }

      const body = renderTrustConfirm({
        customerId: t.customer.id,
        customerName: t.customer.name,
        amount: t.amount.toFixed(2),
        year: new Date().getFullYear(),
      });

      const attempt = await this.deliver({
        notificationType: "TRUST_PAYMENT_CONFIRMATION",
        servicio: "TRUST",
        customerId: t.customer.id,
        customerName: t.customer.name,
        customerEmail: email,
        subject: "Jorge Cuadros - Trust Payment Confirmation",
        bodySnapshot: body,
        debug,
      });
      if (attempt === "SENT") sent++;
      else if (attempt === "SKIPPED_NO_EMAIL" || attempt === "SKIPPED_GATE") skipped++;
      else failed++;
    }

    const response: NotificationJobResponse = {
      request: "success",
      notificationType: "sendTrustPaymentConfirmation",
      confirmationSent: `${sent} of ${total}`,
      statusCode: 200,
      sent,
      skipped,
      failed,
      debug,
      type: "TRUST_PAYMENT_CONFIRMATION",
    };
    await this.adminSummary("Jorge Cuadros - Trust Payment Confirmations Sent", response);
    return response;
  }

  /**
   * "Ejecutar todos" — run all four jobs back to back with one set of flags.
   *
   * Sequential on purpose: the jobs share the SES transport and Job 3 can
   * self-throttle via `useEmailLimit`, so firing them in parallel would both
   * defeat that pause and interleave `email_notification_log` writes for no
   * gain. A job that throws is captured and the sweep continues — one bad
   * query must not swallow the other three envíos.
   */
  async runAll(flags: {
    debug?: boolean;
    ignoreDayRestriction?: boolean;
    useEmailLimit?: boolean;
  }): Promise<NotificationRunAllResponse> {
    const debug = !!flags.debug;
    const steps: {
      kind: NotificationJobKind;
      run: () => Promise<NotificationJobResponse>;
    }[] = [
      { kind: "outstanding", run: () => this.runOutstandingPayments(flags) },
      { kind: "payment", run: () => this.runPaymentConfirmation(flags) },
      { kind: "account", run: () => this.runAccountStatus(flags) },
      { kind: "trust", run: () => this.runTrustConfirmation(flags) },
    ];

    const jobs: NotificationRunAllJobResult[] = [];
    let sent = 0;
    let skipped = 0;
    let failed = 0;
    let errors = 0;

    for (const step of steps) {
      try {
        const result = await step.run();
        sent += result.sent;
        skipped += result.skipped;
        failed += result.failed;
        jobs.push({ kind: step.kind, ok: true, result });
      } catch (e) {
        errors++;
        const message = e instanceof Error ? e.message : String(e);
        this.logger.error(`run-all: job ${step.kind} failed — ${message}`);
        jobs.push({ kind: step.kind, ok: false, error: message });
      }
    }

    return {
      request: "success",
      notificationType: "runAllNotifications",
      statusCode: 200,
      debug,
      sent,
      skipped,
      failed,
      errors,
      jobs,
      type: "RUN_ALL",
    };
  }

  /* ============================================================================
   *  Log browser — list / drill-down for the UI.
   * ========================================================================== */

  /** Recent notification log rows, newest first, with optional filters. */
  async listLog(params: {
    page: number;
    pageSize: number;
    type?: EmailNotificationType;
    /** Empty/omitted = every servicio. The /notificaciones tabs pass their
     *  own slice (Servicios: CUSTOMERS+TRUST, Pólizas: POLICIES). */
    servicio?: EmailNotificationServicio[];
    status?: EmailNotificationStatus[];
    customerId?: string;
  }) {
    const where: Prisma.EmailNotificationLogWhereInput = {};
    if (params.type) where.notificationType = params.type;
    if (params.servicio?.length) where.servicio = { in: params.servicio };
    if (params.status?.length) where.status = { in: params.status };
    if (params.customerId) where.customerId = params.customerId;

    const [total, rows] = await this.prisma.$transaction([
      this.prisma.emailNotificationLog.count({ where }),
      this.prisma.emailNotificationLog.findMany({
        where,
        orderBy: { sendDate: "desc" },
        skip: (params.page - 1) * params.pageSize,
        take: params.pageSize,
        select: {
          id: true,
          sendDate: true,
          notificationType: true,
          level: true,
          servicio: true,
          customerId: true,
          customerName: true,
          customerEmail: true,
          subject: true,
          debug: true,
          status: true,
          providerMessageId: true,
          error: true,
        },
      }),
    ]);

    return {
      items: rows,
      total,
      page: params.page,
      pageSize: params.pageSize,
      pageCount: Math.ceil(total / params.pageSize),
    };
  }

  /** Per-type + per-status counts for the dashboard header. Scoped by
   *  servicio so each /notificaciones tab reports its own totals instead of
   *  the whole platform's. */
  async stats(servicio?: EmailNotificationServicio[]) {
    const where: Prisma.EmailNotificationLogWhereInput = servicio?.length
      ? { servicio: { in: servicio } }
      : {};

    const [byType, byStatus, byServicio, lastRun] = await Promise.all([
      this.prisma.emailNotificationLog.groupBy({
        by: ["notificationType", "status"],
        where,
        _count: { _all: true },
      }),
      this.prisma.emailNotificationLog.groupBy({
        by: ["status"],
        where,
        _count: { _all: true },
      }),
      this.prisma.emailNotificationLog.groupBy({
        by: ["servicio", "status"],
        where,
        _count: { _all: true },
      }),
      this.prisma.emailNotificationLog.findFirst({
        where,
        orderBy: { sendDate: "desc" },
        select: { sendDate: true, notificationType: true },
      }),
    ]);

    return {
      byType,
      byStatus,
      byServicio,
      lastRun,
      transport: {
        available: this.mail.available,
        devFallback: this.mail.isDevFallback,
      },
    };
  }

  /* ============================================================================
   *  Internals — send / log / balance helpers.
   * ========================================================================== */

  /** Where debug=1 sends everything. Shared with the renewal sweep so both
   *  halves of /notificaciones divert to the same inbox. */
  private debugEmail(): string {
    return DEBUG_RECIPIENT;
  }

  /** The customer's `minimumBalance`, or null if unset. The legacy TIPO
   *  was 50/100/200/300/500; the new schema encodes this as
   *  `Customer.minimumBalance` (the office already sets it per row). */
  private async customerMinimum(
    customerId: string,
    _balance: number,
  ): Promise<number | null> {
    const c = await this.prisma.customer.findUnique({
      where: { id: customerId },
      select: { minimumBalance: true },
    });
    if (!c?.minimumBalance) return null;
    const m = Number(c.minimumBalance.toString());
    return isFinite(m) && m > 0 ? m : null;
  }

  /** Send one account-status alert + write the parallel history row. */
  private async sendAccountStatusAlert(args: {
    customerId: string;
    customerName: string;
    customerEmail: string;
    balance: number;
    tipo: number;
    level: 0 | 1;
    minBalance: number | null;
    debug: boolean;
  }): Promise<boolean> {
    const body = renderAccountStatus({
      customerId: args.customerId,
      customerName: args.customerName,
      level: args.level,
      balance: args.balance.toFixed(2),
      tipo: args.tipo.toFixed(2),
      year: new Date().getFullYear(),
    });
    const status = await this.deliver({
      notificationType: "ACCOUNT_STATUS",
      level: args.level,
      servicio: "CUSTOMERS",
      customerId: args.customerId,
      customerName: args.customerName,
      customerEmail: args.customerEmail,
      subject: "Jorge Cuadros - Account Status Alert",
      bodySnapshot: body,
      debug: args.debug,
    });
    if (status === "SENT") {
      // Mirrors `utility_dbo.send_account_status_history`. The legacy
      // SOLICITADO formula was `0 - TIPO - BALANCE`; for yellow that
      // simplifies to `minBalance - balance` (top-up amount), for red to
      // `|balance|` (rush amount). We preserve the legacy `tipo` column
      // as the human-readable label so downstream reports keep working.
      await this.prisma.accountStatusHistory.create({
        data: {
          customerId: args.customerId,
          customerName: args.customerName,
          customerEmail: args.customerEmail,
          tipo: args.level === 0 ? "DEBAJO DEL TIPO" : "EN ROJO",
          balance: new Prisma.Decimal(args.balance.toFixed(2)),
          solicitado: new Prisma.Decimal(
            (args.level === 0
              ? args.minBalance! - args.balance
              : Math.abs(args.balance)
            ).toFixed(2),
          ),
          level: args.level,
        },
      });
      return true;
    }
    return false;
  }

  /** Send one email + write the log row. Returns the final attempt
   *  status (SENT / FAILED / SKIPPED_*) so the caller can update its
   *  counters without re-querying the DB. */
  private async deliver(attempt: SendAttempt & { debug: boolean }): Promise<AttemptStatus> {
    try {
      const { messageId, response } = await this.mail.send({
        to: attempt.customerEmail,
        toName: attempt.customerName,
        subject: attempt.subject,
        html: attempt.bodySnapshot,
        xTracking: attempt.debug ? "debug" : "1",
      });
      await this.recordAttempt({
        ...attempt,
        providerMessageId: messageId || undefined,
        providerResponse: response || undefined,
        status: "SENT",
      });
      return "SENT";
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      // ServiceUnavailableException means no transport was configured and
      // we are not in dev — surface that as a clear FAILED row rather than
      // letting it abort the whole job mid-loop.
      this.logger.warn(
        `Notification send failed for ${attempt.customerName} <${attempt.customerEmail}>: ${message}`,
      );
      await this.recordAttempt({
        ...attempt,
        status: "FAILED",
        error: message.slice(0, 4096),
      });
      return "FAILED";
    }
  }

  /** Persist one notification log row. Thin pass-through to the shared
   *  writer — the renewal sweep writes the same rows through the same
   *  service, which is what keeps /notificaciones' log complete. */
  private async recordAttempt(args: {
    notificationType: EmailNotificationType;
    servicio: EmailNotificationServicio;
    customerId: string | null;
    customerName: string;
    customerEmail: string;
    subject: string;
    bodySnapshot: string;
    bodyRequestUrl?: string;
    level?: 0 | 1;
    status: AttemptStatus;
    debug: boolean;
    providerMessageId?: string;
    providerResponse?: string;
    error?: string;
  }) {
    await this.log.record(args);
  }

  /** Send the admin summary email after every job. The PHP sent one to
   *  each admin address; we do the same. Body = JSON-stringified
   *  response so it matches the legacy format verbatim. */
  private async adminSummary(subject: string, response: NotificationJobResponse) {
    const body = JSON.stringify(response);
    // Resolved per job, not cached at boot: the recipient list is edited in
    // the UI while the app is running (SettingsService), and a cached copy
    // would put us back to needing a restart for it to take effect.
    const { value: adminEmails } = await this.settings.notificationAdminEmails();
    for (const to of adminEmails) {
      try {
        const { messageId } = await this.mail.send({
          to,
          toName: "Jorge Cuadros Admin",
          subject,
          html: `<pre style="font-family:monospace;font-size:12px;">${body.replace(
            /[<>&]/g,
            (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] ?? c,
          )}</pre>`,
          xTracking: "admin-summary",
        });
        this.logger.log(
          `Admin summary sent to ${to}: subject="${subject}" id=${messageId}`,
        );
      } catch (err) {
        this.logger.warn(
          `Admin summary to ${to} failed: ${(err as Error).message}`,
        );
      }
    }
  }

  /** Available for tests + future cron to assert before launching a sweep. */
  get transportAvailable(): boolean {
    return this.mail.available;
  }
}
