/**
 * The catalog. One entry per report. Adding a new report is one new entry
 * here — no new route, no new page, no new component.
 *
 * Filter behavior to keep in mind:
 *   - Currency balances are NEVER summed across currencies (912 customers
 *     carry both MXN and USD; the legacy data has no FX per row, so any
 *     cross-currency total would be invented). Every report that touches
 *     the ledger accepts a `currency` filter and reports per currency.
 *   - Voided transactions must be excluded from totals (NOT_VOIDED). The
 *     UI still shows them struck-through; the SQL drops them.
 *   - The legacy `REPORTE DE EFECTIVO` covered cash receipts only. In the
 *     new schema those are `Transaction` rows with `legacySourceTable` in
 *     the EFECTIVO* set OR `checkNumber` null + amount > 0 (true cash).
 */

import { Prisma } from "@jorgecuadros/database";
import {
  intParam,
  NOT_VOIDED,
  parseDate,
  type ReportDef,
} from "./reports.types";
import {
  renewalLetterSelect,
  toRenewalLetterRow,
} from "./renewal-letter";

/* ------------------------------------------------------------------ helpers */

function nameOf(c: { name: string; nameMissing: boolean }): string {
  return c.nameMissing ? "(sin nombre)" : c.name;
}

/* ------------------------------------------------------------------ reports */

/**
 * LISTADO EN ROJO — overdue customers worklist.
 * Same data as the receivables worklist with balance=owing, but presented
 * as a printable report rather than a paginated browser.
 */
const listadoEnRojo: ReportDef = {
  slug: "listado-en-rojo",
  title: "Clientes en rojo",
  description:
    "Cartera vencida: clientes con saldo deudor en la moneda seleccionada, " +
    "ordenados del más antiguo al más reciente.",
  domain: "estado-cuenta",
  legacyName: "LISTADO EN ROJO",
  format: "tabular",
  params: [
    {
      key: "currency",
      label: "Moneda",
      kind: "select",
      options: [
        { value: "MXN", label: "MXN" },
        { value: "USD", label: "USD" },
      ],
      defaultValue: "MXN",
    },
    {
      key: "query",
      label: "Buscar (nombre o ciudad)",
      kind: "text",
      placeholder: "Ej. Pérez, Tijuana…",
    },
  ],
  columns: [
    { key: "id", label: "#", type: "text" },
    { key: "name", label: "Cliente", type: "text" },
    { key: "city", label: "Ciudad", type: "text" },
    { key: "movements", label: "Movs.", type: "number", align: "right" },
    { key: "balance", label: "Saldo", type: "money", align: "right" },
    { key: "lastMovement", label: "Último movimiento", type: "date" },
  ],
  async run(prisma, p) {
    const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
    const q = p.query?.trim();
    const nameFilter = q
      ? Prisma.sql`AND (c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})`
      : Prisma.empty;

    const bal =
      currency === "USD"
        ? Prisma.sql`SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END)`
        : Prisma.sql`SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END)`;

    const rows = await prisma.$queryRaw<
      Array<{
        id: string;
        name: string;
        nameMissing: boolean;
        city: string | null;
        movements: bigint | number | string;
        balance: Prisma.Decimal | null;
        lastMovement: Date | null;
      }>
    >`
      SELECT c.id, c.name, c.nameMissing, c.city,
             COUNT(*) AS movements,
             ${bal} AS balance,
             MAX(t.transactionDate) AS lastMovement
      FROM customers c
      JOIN transactions t ON t.customerId = c.id
      WHERE t.voidedAt IS NULL ${nameFilter}
      GROUP BY c.id, c.name, c.nameMissing, c.city
      HAVING ${bal} < -0.005
      ORDER BY MAX(t.transactionDate) ASC, c.nameMissing ASC, c.name ASC
    `;

    let totalBalance = new Prisma.Decimal(0);
    let totalMovs = 0;
    const out = rows.map((r) => {
      const b = r.balance ?? new Prisma.Decimal(0);
      totalBalance = totalBalance.plus(b);
      totalMovs += Number(r.movements);
      return {
        id: r.id.slice(0, 8),
        name: nameOf(r),
        city: r.city ?? "—",
        movements: Number(r.movements),
        balance: b.toFixed(2),
        lastMovement: r.lastMovement
          ? r.lastMovement.toISOString().slice(0, 10)
          : "—",
      };
    });

    return {
      rows: out,
      totals: {
        customers: out.length,
        movements: totalMovs,
        balance: totalBalance.toFixed(2),
        currency,
      },
      subtitle: `Moneda: ${currency} · ${out.length} clientes con saldo deudor`,
    };
  },
};

/**
 * PAGOS NO EFECTUADOS (AGUA / LUZ / TEL).
 * Customers enrolled in a service (by PropertyService.kind) with no
 * related ledger charge in the last N days. Heuristic: any non-voided
 * charge-type transaction in the window counts as "they paid". The
 * report groups by property so the same customer with two water meters
 * appears once per property.
 */
const pagosNoEfectuados: ReportDef = {
  slug: "pagos-no-efectuados",
  title: "Pagos no efectuados",
  description:
    "Clientes con un servicio contratado (agua, luz o teléfono) sin " +
    "movimientos de cargo en los últimos N días. Heurística basada en el " +
    "servicio registrado en la propiedad y la ausencia de cargos en el " +
    "periodo seleccionado.",
  domain: "servicios",
  legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL",
  format: "tabular",
  params: [
    {
      key: "serviceKind",
      label: "Servicio",
      kind: "select",
      options: [
        { value: "WATER", label: "Agua" },
        { value: "ELECTRICITY", label: "Luz" },
        { value: "PHONE", label: "Teléfono" },
      ],
      defaultValue: "WATER",
    },
    {
      key: "days",
      label: "Días sin movimiento",
      kind: "number",
      defaultValue: "60",
    },
    {
      key: "currency",
      label: "Moneda",
      kind: "select",
      options: [
        { value: "MXN", label: "MXN" },
        { value: "USD", label: "USD" },
      ],
      defaultValue: "MXN",
    },
  ],
  columns: [
    { key: "customerId", label: "Cliente #", type: "text" },
    { key: "customerName", label: "Cliente", type: "text" },
    { key: "propertyAddress", label: "Dirección", type: "text" },
    { key: "accountNumber", label: "Cuenta / Medidor", type: "text" },
    { key: "lastCharge", label: "Último cargo", type: "date" },
    { key: "balance", label: "Saldo", type: "money", align: "right" },
  ],
  async run(prisma, p) {
    const kind = (p.serviceKind ?? "WATER") as
      | "WATER"
      | "ELECTRICITY"
      | "PHONE";
    const days = intParam(p, "days", 60, 1, 365);
    const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
    const cutoff = new Date(Date.now() - days * 86400000);

    // Customers enrolled in the service on a non-archived property, with no
    // charge-type transaction in the window. The subquery picks up
    // *anything* the customer paid (any domain, any type) — close enough
    // for the staff's "who's overdue" view; the precise per-service match
    // would need a per-service typeId taxonomy that doesn't exist in the
    // legacy data.
    const rows = await prisma.$queryRaw<
      Array<{
        customerId: string;
        customerName: string;
        nameMissing: boolean;
        propertyId: string;
        propertyAddress: string | null;
        accountNumber: string | null;
        lastCharge: Date | null;
        balance: Prisma.Decimal | null;
      }>
    >`
      SELECT
        c.id          AS customerId,
        c.name        AS customerName,
        c.nameMissing AS nameMissing,
        pr.id         AS propertyId,
        pr.addressLine1 AS propertyAddress,
        ps.accountNumber AS accountNumber,
        (SELECT MAX(t.transactionDate) FROM transactions t
            WHERE t.customerId = c.id AND t.voidedAt IS NULL
              AND t.amount < 0
              AND t.transactionDate >= ${cutoff}) AS lastCharge,
        (SELECT SUM(t.amount) FROM transactions t
            WHERE t.customerId = c.id AND t.voidedAt IS NULL
              AND t.currency = ${currency}) AS balance
      FROM property_services ps
      JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
      JOIN customers c   ON c.id = pr.customerId AND c.archivedAt IS NULL
      WHERE ps.kind = ${kind} AND ps.active = 1
        AND NOT EXISTS (
          SELECT 1 FROM transactions t
          WHERE t.customerId = c.id AND t.voidedAt IS NULL
            AND t.amount < 0 AND t.transactionDate >= ${cutoff}
        )
      ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
    `;

    let totalBalance = new Prisma.Decimal(0);
    const out = rows.map((r) => {
      const b = r.balance ?? new Prisma.Decimal(0);
      totalBalance = totalBalance.plus(b);
      return {
        customerId: r.customerId.slice(0, 8),
        customerName: nameOf({
          name: r.customerName,
          nameMissing: r.nameMissing,
        }),
        propertyAddress: r.propertyAddress ?? "—",
        accountNumber: r.accountNumber ?? "—",
        lastCharge: r.lastCharge
          ? r.lastCharge.toISOString().slice(0, 10)
          : "—",
        balance: b.toFixed(2),
      };
    });

    return {
      rows: out,
      totals: {
        rows: out.length,
        balance: totalBalance.toFixed(2),
        currency,
      },
      subtitle: `Servicio: ${
        kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
      } · ${days} días · ${out.length} propiedades sin cargo reciente`,
    };
  },
};

/**
 * FALTANTES DE (AGUA / LUZ / TEL).
 * Data-quality report: properties enrolled in a service that are missing
 * the key identifier the legacy system required (account/meter/route).
 * Different `faltante` per service kind in the legacy because the
 * service's identifier fields differ; here we flag any of the three
 * common identifiers being blank.
 */
const faltantes: ReportDef = {
  slug: "faltantes",
  title: "Faltantes de datos por servicio",
  description:
    "Calidad de datos: propiedades con un servicio contratado que no " +
    "tienen número de cuenta, medidor o ruta registrado. El reporte que " +
    "en la legacy corría como FALTANTES DE AGUA / LUZ / TEL.",
  domain: "servicios",
  legacyName: "FALTANTES DE AGUA/LUZ/TEL",
  format: "tabular",
  params: [
    {
      key: "serviceKind",
      label: "Servicio",
      kind: "select",
      options: [
        { value: "WATER", label: "Agua" },
        { value: "ELECTRICITY", label: "Luz" },
        { value: "PHONE", label: "Teléfono" },
      ],
      defaultValue: "WATER",
    },
  ],
  columns: [
    { key: "customerId", label: "Cliente #", type: "text" },
    { key: "customerName", label: "Cliente", type: "text" },
    { key: "propertyAddress", label: "Dirección", type: "text" },
    { key: "missing", label: "Faltante", type: "text" },
    { key: "dueDay", label: "Día de vencimiento", type: "text" },
  ],
  async run(prisma, p) {
    const kind = (p.serviceKind ?? "WATER") as
      | "WATER"
      | "ELECTRICITY"
      | "PHONE";

    // A row per (property, missing field). The "missing" string describes
    // what's blank so the report is self-explanatory when printed.
    const rows = await prisma.$queryRaw<
      Array<{
        customerId: string;
        customerName: string;
        nameMissing: boolean;
        propertyId: string;
        propertyAddress: string | null;
        dueDay: string | null;
        missing: string;
      }>
    >`
      SELECT
        c.id          AS customerId,
        c.name        AS customerName,
        c.nameMissing AS nameMissing,
        pr.id         AS propertyId,
        pr.addressLine1 AS propertyAddress,
        ps.dueDay     AS dueDay,
        CASE
          WHEN ps.accountNumber IS NULL OR ps.accountNumber = '' THEN 'Sin número de cuenta'
          WHEN ps.meterNumber   IS NULL OR ps.meterNumber   = '' THEN 'Sin número de medidor'
          WHEN ps.route         IS NULL OR ps.route         = '' THEN 'Sin ruta'
          ELSE ''
        END AS missing
      FROM property_services ps
      JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
      JOIN customers c   ON c.id = pr.customerId AND c.archivedAt IS NULL
      WHERE ps.kind = ${kind} AND ps.active = 1
        AND (
          ps.accountNumber IS NULL OR ps.accountNumber = ''
          OR ps.meterNumber IS NULL OR ps.meterNumber = ''
          OR ps.route IS NULL OR ps.route = ''
        )
      ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
    `;

    const out = rows.map((r) => ({
      customerId: r.customerId.slice(0, 8),
      customerName: nameOf({
        name: r.customerName,
        nameMissing: r.nameMissing,
      }),
      propertyAddress: r.propertyAddress ?? "—",
      missing: r.missing,
      dueDay: r.dueDay ?? "—",
    }));

    return {
      rows: out,
      totals: { rows: out.length },
      subtitle: `Servicio: ${
        kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
      } · ${out.length} propiedades con datos faltantes`,
    };
  },
};

/**
 * REPORTE DE EFECTIVO — cash reconciliation.
 * Credits in the EFECTIVO* legacy source tables OR with no cheque number
 * (true cash) within a date range. Excludes voided rows. Matches the
 * shape of the legacy REPORTE DE EFECTIVO report.
 */
const reporteDeEfectivo: ReportDef = {
  slug: "reporte-de-efectivo",
  title: "Reporte de efectivo",
  description:
    "Recibos de efectivo en el periodo seleccionado. Cubre los abonos " +
    "provenientes de las tablas legacy EFECTIVO* y los créditos sin " +
    "número de cheque (efectivo real). El match del reporte original.",
  domain: "chequera",
  legacyName: "REPORTE DE EFECTIVO",
  format: "tabular",
  params: [
    { key: "from", label: "Desde", kind: "date" },
    { key: "to", label: "Hasta", kind: "date", endOfDay: true },
    {
      key: "currency",
      label: "Moneda",
      kind: "select",
      options: [
        { value: "MXN", label: "MXN" },
        { value: "USD", label: "USD" },
      ],
      defaultValue: "MXN",
    },
  ],
  columns: [
    { key: "date", label: "Fecha", type: "date" },
    { key: "customerName", label: "Cliente", type: "text" },
    { key: "concept", label: "Concepto", type: "text" },
    { key: "source", label: "Origen", type: "text" },
    { key: "amount", label: "Monto", type: "money", align: "right" },
  ],
  async run(prisma, p) {
    const from = parseDate(p.from);
    const to = parseDate(p.to, true);
    const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";

    const ands: Prisma.TransactionWhereInput[] = [
      NOT_VOIDED,
      { amount: { gt: 0 } },
      { currency },
      {
        OR: [
          { legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } },
          {
            AND: [
              { checkNumber: null },
              { legacySourceTable: { not: "CHEQUE FM3" } },
            ],
          },
        ],
      },
    ];
    if (from || to) {
      ands.push({
        transactionDate: {
          ...(from ? { gte: from } : {}),
          ...(to ? { lte: to } : {}),
        },
      });
    }

    const rows = await prisma.transaction.findMany({
      where: { AND: ands },
      orderBy: { transactionDate: "asc" },
      select: {
        transactionDate: true,
        amount: true,
        reference: true,
        checkNumber: true,
        message: true,
        legacySourceTable: true,
        type: { select: { nameEs: true, nameEn: true } },
        customer: { select: { name: true, nameMissing: true } },
      },
    });

    let total = new Prisma.Decimal(0);
    const out = rows.map((r) => {
      total = total.plus(r.amount);
      return {
        date: r.transactionDate.toISOString().slice(0, 10),
        customerName: nameOf(r.customer),
        concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
        source: r.legacySourceTable ?? "—",
        amount: r.amount.toFixed(2),
      };
    });

    return {
      rows: out,
      totals: {
        rows: out.length,
        total: total.toFixed(2),
        currency,
      },
      subtitle: `Efectivo · ${currency} · ${out.length} recibos${
        from ? ` desde ${p.from}` : ""
      }${to ? ` hasta ${p.to}` : ""}`,
    };
  },
};

/**
 * VIGENTE (LIC / INCEN / MULT / …) — policies up for renewal.
 * Wraps the policy listing with status=expiring and a policy-type filter,
 * sorted by soonest expiry. The legacy VIGENTE LIC / INCEN / MULT
 * reports are the same data; the new filter is a dropdown.
 */
const vigente: ReportDef = {
  slug: "vigente",
  title: "Pólizas por vencer",
  description:
    "Pólizas que vencen en los próximos N días, filtradas por ramo. " +
    "Equivalente a los reportes VIGENTE LIC / INCEN / MULT de la legacy.",
  domain: "polizas",
  legacyName: "VIGENTE LIC/INCEN/MULT",
  format: "tabular",
  params: [
    {
      key: "typeName",
      label: "Ramo",
      kind: "select",
      options: [
        { value: "LICENCIAS", label: "Licencias" },
        { value: "INCENDIO", label: "Incendio" },
        { value: "MULT", label: "Multirriesgo" },
        { value: "MCA2", label: "MCA2 (auto)" },
        { value: "ME", label: "ME" },
        { value: "MF", label: "MF" },
        { value: "RC", label: "RC" },
        { value: "INCEN", label: "Incen" },
        { value: "TAMPL", label: "TAMPL" },
        { value: "FAMILIAR", label: "Familiar" },
      ],
      defaultValue: "LICENCIAS",
    },
    {
      key: "days",
      label: "Ventana (días)",
      kind: "number",
      defaultValue: "30",
    },
  ],
  columns: [
    { key: "policyNumber", label: "Póliza", type: "text" },
    { key: "customerName", label: "Cliente", type: "text" },
    { key: "provider", label: "Aseguradora", type: "text" },
    { key: "agent", label: "Agente", type: "text" },
    { key: "from", label: "Desde", type: "date" },
    { key: "to", label: "Vence", type: "date" },
    { key: "daysToExpire", label: "Días", type: "number", align: "right" },
    { key: "premium", label: "Prima neta", type: "money", align: "right" },
  ],
  async run(prisma, p) {
    const typeName = p.typeName ?? "LICENCIAS";
    const days = intParam(p, "days", 30, 1, 365);
    const now = new Date();
    const today = new Date(
      Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
    );
    const soon = new Date(today.getTime() + days * 86400000);

    const rows = await prisma.policy.findMany({
      where: {
        policyType: { name: typeName },
        archivedAt: null,
        policyTo: { gte: today, lte: soon },
      },
      orderBy: { policyTo: "asc" },
      select: {
        policyNumber: true,
        policyFrom: true,
        policyTo: true,
        netPremium: true,
        agentName: true,
        customer: { select: { name: true, nameMissing: true } },
        insuranceProvider: { select: { name: true } },
      },
    });

    let totalPremium = new Prisma.Decimal(0);
    const out = rows.map((r) => {
      const daysTo = r.policyTo
        ? Math.round(
            (r.policyTo.getTime() - today.getTime()) / 86400000,
          )
        : 0;
      if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
      return {
        policyNumber: r.policyNumber,
        customerName: nameOf(r.customer),
        provider: r.insuranceProvider?.name ?? "—",
        agent: r.agentName ?? "—",
        from: r.policyFrom ? r.policyFrom.toISOString().slice(0, 10) : "—",
        to: r.policyTo ? r.policyTo.toISOString().slice(0, 10) : "—",
        daysToExpire: daysTo,
        premium: r.netPremium ? r.netPremium.toFixed(2) : "0.00",
      };
    });

    return {
      rows: out,
      totals: {
        rows: out.length,
        premium: totalPremium.toFixed(2),
      },
      subtitle: `Ramo: ${typeName} · ${days} días · ${out.length} pólizas por vencer`,
    };
  },
};

/**
 * AVISO DE RENOVACION — insurance renewal notice.
 *
 * Replaces ~40 legacy report clones (one per carrier per coverage tier —
 * `AMPL R RENEW X MES NEW ATLAS 13`, `... QUALITAS ...`, `LIC RENEW X
 * VENCE ATLAS 2013`, etc., see docs/RENEWAL_NOTICES.md) with one
 * parameterized report: pick the ramo, the expiry month/year, which
 * notice generation (1st/2nd/3rd, mirroring the legacy RENEW/RENEW2/
 * RENEW3 escalation), and optionally a carrier filter.
 *
 * The legacy reports hardcoded per-policy figures (deductible, CSL limit,
 * premium) as static label text re-typed by hand for every new rate/
 * carrier clone. Here they're read from real columns / `coveragesJson`
 * (see docs/RENEWAL_NOTICES.md's column-mapping table) so one template
 * covers every carrier and tier instead of a clone per combination.
 *
 * `sentStatus` is read from `RenewalNotice` (schema.prisma) — the
 * replacement for the legacy `CONTROL <ramo> RENEW[2/3] X MES` paper log.
 */
const avisoRenovacion: ReportDef = {
  slug: "aviso-renovacion",
  title: "Aviso de renovación",
  description:
    "Cartas de aviso de renovación para pólizas por vencer en el mes y " +
    "año seleccionados, con la generación de aviso (1a/2a/3a) y filtro " +
    "opcional por aseguradora. Sustituye a los ~40 reportes clonados por " +
    "aseguradora/cobertura de la legacy (ver docs/RENEWAL_NOTICES.md).",
  domain: "polizas",
  legacyName:
    "AMPL R RENEW X MES NEW ATLAS 13 / RC RENEW X MES NEW ATLAS 13 / " +
    "LIC RENEW X VENCE ATLAS 2013 / RCR RENEW X MES NEWATLAS 2013 (y " +
    "sus clones por aseguradora y cobertura)",
  format: "letter",
  params: [
    {
      key: "policyType",
      label: "Ramo",
      kind: "select",
      options: [
        { value: "AUTO", label: "Auto" },
        { value: "LICENCIAS", label: "Licencias" },
        { value: "INCENDIO", label: "Incendio" },
        { value: "MULT", label: "Multirriesgo" },
        { value: "M_EMPR", label: "M Empresarial" },
      ],
      defaultValue: "AUTO",
    },
    {
      key: "month",
      label: "Mes de vencimiento (1-12)",
      kind: "number",
      defaultValue: String(new Date().getUTCMonth() + 1),
    },
    {
      key: "year",
      label: "Año de vencimiento",
      kind: "number",
      defaultValue: String(new Date().getUTCFullYear()),
    },
    {
      key: "generation",
      label: "Generación de aviso",
      kind: "select",
      options: [
        { value: "1", label: "1er aviso" },
        { value: "2", label: "2o aviso" },
        { value: "3", label: "3er aviso" },
      ],
      defaultValue: "1",
    },
    {
      key: "provider",
      label: "Aseguradora (opcional)",
      kind: "text",
      placeholder: "Ej. ATLAS, QUALITAS…",
    },
  ],
  // Flat columns so CSV/XLSX/generic PDF exports stay useful even though
  // the on-screen view renders each row as a full letter (LetterLayout in
  // ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement".
  columns: [
    { key: "policyNumber", label: "Póliza", type: "text" },
    { key: "customerName", label: "Cliente", type: "text" },
    { key: "provider", label: "Aseguradora", type: "text" },
    { key: "policyTo", label: "Vence", type: "date" },
    { key: "netPremium", label: "Prima neta", type: "money", align: "right" },
    { key: "total", label: "Total", type: "money", align: "right" },
    { key: "generation", label: "Generación", type: "number" },
    { key: "sentAt", label: "Enviado", type: "date" },
  ],
  async run(prisma, p) {
    const typeName = p.policyType ?? "AUTO";
    const month = intParam(p, "month", new Date().getUTCMonth() + 1, 1, 12);
    const year = intParam(p, "year", new Date().getUTCFullYear(), 1990, 2100);
    const generation = intParam(p, "generation", 1, 1, 3);
    const provider = p.provider?.trim();

    const from = new Date(Date.UTC(year, month - 1, 1));
    const to = new Date(Date.UTC(year, month, 1));

    const rows = await prisma.policy.findMany({
      where: {
        policyType: { name: typeName },
        archivedAt: null,
        policyTo: { gte: from, lt: to },
        ...(provider
          ? { insuranceProvider: { name: { contains: provider } } }
          : {}),
      },
      orderBy: { policyTo: "asc" },
      select: renewalLetterSelect(generation),
    });

    let totalPremium = new Prisma.Decimal(0);
    let sentCount = 0;
    const out = rows.map((r) => {
      if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
      const letter = toRenewalLetterRow(r, generation);
      if (letter.sentAt) sentCount++;
      return letter;
    });

    return {
      rows: out,
      totals: {
        cartas: out.length,
        enviadas: sentCount,
        pendientes: out.length - sentCount,
        primaTotal: totalPremium.toFixed(2),
      },
      subtitle: `Ramo: ${typeName} · vencen ${String(month).padStart(2, "0")}/${year} · generación ${generation}${
        provider ? ` · aseguradora: ${provider}` : ""
      } · ${out.length} avisos`,
    };
  },
};

/**
 * EDO CUENTA DATOS — per-customer account statement.
 * Wraps the existing BillingService.statement() output. The full layout
 * (header, balance per currency, by-domain split, by-type breakdown,
 * full movement list with running balance) is rendered by the statement
 * page; this report is the same data with print/PDF/CSV/XLSX outputs.
 */
const edoCuentaDatos: ReportDef = {
  slug: "edo-cuenta-datos",
  title: "Estado de cuenta",
  description:
    "Estado de cuenta de un cliente: saldos por moneda, desglose por " +
    "ramo y concepto, y el historial completo de movimientos con saldo " +
    "corrido. El reporte del cliente final.",
  domain: "estado-cuenta",
  legacyName: "EDO CUENTA DATOS",
  format: "statement",
  params: [
    { key: "customerId", label: "Cliente", kind: "customer-picker" },
  ],
  columns: [
    // Statement rows carry synthetic `__kind` discriminators instead of
    // column keys; the runner renders the special cases inline. These
    // columns drive CSV/XLSX when the user wants a flat movement export.
    { key: "date", label: "Fecha", type: "date" },
    { key: "concept", label: "Concepto", type: "text" },
    { key: "reference", label: "Referencia", type: "text" },
    { key: "amount", label: "Cargo / Abono", type: "money", align: "right" },
    { key: "balanceAfter", label: "Saldo", type: "money", align: "right" },
  ],
  async run(prisma, p) {
    const customerId = p.customerId;
    if (!customerId) {
      return { rows: [], subtitle: "Selecciona un cliente" };
    }
    const customer = await prisma.customer.findUnique({
      where: { id: customerId },
      select: {
        id: true,
        name: true,
        nameMissing: true,
        addressLine1: true,
        city: true,
        state: true,
        email: true,
        phone: true,
      },
    });
    if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };

    // Reuse the same NOT_VOIDED + STATEMENT_EXCLUDED_SOURCE_TABLES filter
    // as BillingService.statement so the numbers match what the customer
    // already sees in /estado-cuenta/[id].
    const rows = await prisma.transaction.findMany({
      where: {
        customerId,
        voidedAt: null,
        legacySourceTable: {
          notIn: [
            "EFECTIVO",
            "EFECTIVO_BACKUP",
            "EFECTIVO FM3",
            "CHEQUE FM3",
            "IVA 2015",
          ],
        },
      },
      orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
      select: {
        id: true,
        transactionDate: true,
        domain: true,
        amount: true,
        currency: true,
        reference: true,
        period: true,
        checkNumber: true,
        message: true,
        legacySourceTable: true,
        type: { select: { nameEs: true, nameEn: true } },
      },
    });

    // Compute running balance per currency, then return newest-first.
    const running = new Map<string, Prisma.Decimal>();
    const movements = rows.map((r) => {
      const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
      const next = prev.plus(r.amount);
      running.set(r.currency, next);
      return {
        date: r.transactionDate.toISOString().slice(0, 10),
        domain: r.domain,
        currency: r.currency,
        reference: r.reference ?? "",
        period: r.period ?? "",
        checkNumber: r.checkNumber ?? "",
        concept: r.type?.nameEs ?? r.type?.nameEn ?? "—",
        amount: r.amount.toFixed(2),
        balanceAfter: next.toFixed(2),
      };
    });
    movements.reverse();

    // Per-currency summary + per-domain breakdown.
    const perCurrency = new Map<
      string,
      { currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number }
    >();
    for (const r of rows) {
      const c =
        perCurrency.get(r.currency) ??
        {
          currency: r.currency,
          charges: new Prisma.Decimal(0),
          credits: new Prisma.Decimal(0),
          count: 0,
        };
      c.count += 1;
      if (r.amount.lessThan(0)) c.charges = c.charges.plus(r.amount);
      else c.credits = c.credits.plus(r.amount);
      perCurrency.set(r.currency, c);
    }

    return {
      rows: [
        {
          __kind: "header",
          name: nameOf(customer),
          address: customer.addressLine1 ?? "",
          city: [customer.city, customer.state].filter(Boolean).join(", "),
          phone: customer.phone ?? "",
          email: customer.email ?? "",
        },
        ...[...perCurrency.values()].map((c) => ({
          __kind: "summary",
          currency: c.currency,
          charges: c.charges.toFixed(2),
          credits: c.credits.toFixed(2),
          balance: c.charges.plus(c.credits).toFixed(2),
          count: c.count,
        })),
        { __kind: "movements-header" },
        ...movements,
      ],
      subtitle: `${nameOf(customer)} · ${rows.length} movimientos`,
    };
  },
};

/**
 * REPORTE CHEQUE COUNT — everything captured against one check.
 *
 * The reconciliation half of the batch-capture flow (docs/RECEIPT_CAPTURE_SPEC
 * §1.3): staff key many customers' receipts against one physical check, then
 * check that what was captured adds up to what the check was cut for. Replaces
 * `EDITA CHEQUE ALF/COUNT/NUM`, `REPORTE POR CHEQUE` and
 * `REPORTE POR CHEQUE PARA ALFA` — four legacy objects, one parameterized
 * report.
 *
 * Deliberately mirrors `BillingService.byCheck`'s rules rather than inventing
 * its own: voided rows are dropped entirely, and outstanding (NOPAGO) rows are
 * listed but excluded from the total, because the check never funded them.
 */
const chequeCount: ReportDef = {
  slug: "cheque-count",
  title: "Reporte por cheque",
  description:
    "Todos los movimientos capturados contra un mismo cheque, con el total " +
    "para conciliar contra el importe físico del cheque. Los movimientos " +
    "pendientes de pago (sin fondos) se listan pero no suman al total.",
  domain: "estado-cuenta",
  legacyName: "REPORTE CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE COUNT",
  format: "tabular",
  params: [
    {
      key: "checkNumber",
      label: "Número de cheque",
      kind: "text",
      placeholder: "Ej. 10432",
    },
  ],
  columns: [
    { key: "customerName", label: "Cliente", type: "text" },
    { key: "reference", label: "Referencia", type: "text" },
    { key: "period", label: "Periodo", type: "text" },
    { key: "concept", label: "Concepto", type: "text" },
    { key: "transactionDate", label: "Fecha", type: "date" },
    { key: "status", label: "Estado", type: "text" },
    { key: "amount", label: "Importe", type: "money", align: "right" },
  ],
  async run(prisma, p) {
    const checkNumber = p.checkNumber?.trim();
    if (!checkNumber) {
      return {
        rows: [],
        totals: { movimientos: 0 },
        subtitle: "Indique un número de cheque",
      };
    }

    const rows = await prisma.transaction.findMany({
      where: { checkNumber, ...NOT_VOIDED },
      orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
      select: {
        transactionDate: true,
        amount: true,
        currency: true,
        reference: true,
        period: true,
        outstanding: true,
        type: { select: { nameEn: true, nameEs: true } },
        customer: { select: { name: true, nameMissing: true } },
      },
    });

    // Per currency, and never collapsed — same rule as the rest of the ledger.
    const totals = new Map<string, Prisma.Decimal>();
    let outstandingCount = 0;
    for (const r of rows) {
      if (r.outstanding) {
        outstandingCount++;
        continue;
      }
      totals.set(
        r.currency,
        (totals.get(r.currency) ?? new Prisma.Decimal(0)).plus(r.amount),
      );
    }

    const totalsOut: Record<string, string | number> = {
      movimientos: rows.length,
    };
    for (const [currency, sum] of totals) {
      totalsOut[`total ${currency}`] = sum.toFixed(2);
    }
    if (outstandingCount) totalsOut["sin fondos"] = outstandingCount;

    return {
      rows: rows.map((r) => ({
        customerName: nameOf(r.customer),
        reference: r.reference ?? "—",
        period: r.period ?? "—",
        concept: r.type?.nameEs || r.type?.nameEn || "Sin clasificar",
        transactionDate: r.transactionDate.toISOString().slice(0, 10),
        status: r.outstanding ? "Sin fondos" : "Pagado",
        amount: r.amount.toFixed(2),
        currency: r.currency,
      })),
      totals: totalsOut,
      subtitle: `Cheque ${checkNumber} · ${rows.length} movimientos`,
    };
  },
};

/* ------------------------------------------------------------------ export */

export const REPORTS: ReportDef[] = [
  listadoEnRojo,
  pagosNoEfectuados,
  faltantes,
  reporteDeEfectivo,
  vigente,
  avisoRenovacion,
  edoCuentaDatos,
  chequeCount,
];

export function findReport(slug: string): ReportDef | undefined {
  return REPORTS.find((r) => r.slug === slug);
}
