import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { CreateBankMovementDto } from "./bank-movement.dto";
import {
  CreateBankAccountDto,
  CreateBankDto,
  UpdateBankAccountDto,
  UpdateBankDto,
} from "./bank-account.dto";

/**
 * App-voided rows (voidedAt set) are reversed and must leave every
 * income/expense/net total. This is distinct from the legacy zero-amount
 * "void" cheques, which stay as amount-0 rows. List views still show voided
 * rows struck-through.
 */
const NOT_VOIDED: Prisma.BankTransactionWhereInput = { voidedAt: null };

/**
 * Bank register (chequera) module — plan step 7.
 *
 * This is the office's OWN operating checking account, migrated from SCOTHIA's
 * `DATOS I` (ingresos) / `DATOS E` (egresos) into one signed-amount table. It
 * carries no customer FK and is deliberately NOT part of `/estado-cuenta`: that
 * ledger is what customers owe the office, this one is the office's own money.
 * The two must never be added together or shown in the same total.
 *
 * SIGN CONVENTION (set by migration/transform_bank.py):
 *   - positive = ingreso (a deposit into the account)
 *   - negative = egreso (a payment out of it)
 *   - exactly zero = a cancelled/void cheque. 787 of the 791 zero rows say
 *     CANCELADO or VOID in the concept; they are neither an income nor an
 *     expense and are excluded from both sides, the way the ~193 zero rows are
 *     in the customer ledger.
 *
 * ONE ACCOUNT AT A TIME, CURRENCY FROM THE ACCOUNT. The office now keeps more
 * than one chequera (Utilities banks in MXN, Seguros in USD), so every read
 * path here is scoped to exactly one `bankAccountId` — never "all accounts".
 * There is deliberately no currency column on `bank_transactions`: a movement
 * inherits its account's, the way a real bank account doesn't mix currencies.
 * Callers must therefore pass an account id; an unscoped total would sum MXN
 * and USD into a figure that never existed, the same mistake the billing
 * module's per-currency rule exists to prevent.
 *
 * The 22,669 migrated rows are all SCOTHIA = the Utilities MXN account
 * (backfilled by `migration/backfill_bank_accounts.py`), and their
 * `amountInWords` on the egreso side is spelled out in PESOS accordingly.
 *
 * NO CATEGORY DIMENSION. `bank_transactions.categoryId` is NULL on all 22,354
 * rows and this module does not filter or group by it, because the data cannot
 * support it:
 *   - `DATOS E` / `DATOS I` have no ramo column at all — the only columns are
 *     fecha, tipo, num, concepto, ingreso/egreso, operado, notas and (egresos)
 *     cantidad en letra. There is no key to migrate.
 *   - `concepto` is a *payee* name (PAYPAL, CFE, TELEFONOS DEL NOROESTE, and
 *     ~1,900 individual people), not a classification. Zero of the 22,354
 *     concepts match a `business_line_categories` name.
 *   - the 66 categories in TABLA RAMODOS are a property-management expense
 *     chart of accounts (Payroll, Pool (Labor), Gardening, Trash Coll) plus
 *     owner names with property numbers — not the insurance/servicios/
 *     fideicomiso split. Classifying concepts into them would not produce a
 *     business-line breakdown even if it worked.
 * A concept->ramo classifier would therefore be invented data, so the register
 * is browsable by date, payee, amount and cheque number instead.
 */

/** Which side of the register a movement is on. */
export type BankDirection = "income" | "expense" | "void";

/** `operado` in the source: whether the bank has cleared the movement. */
export type BankCleared = "cleared" | "pending";

export type BankSort =
  | "date_desc"
  | "date_asc"
  | "amount_desc"
  | "amount_asc"
  | "reference";

export interface BankListParams {
  /** Which chequera to read. Required — see the module header. */
  bankAccountId: string;
  query?: string;
  page: number;
  pageSize: number;
  direction?: BankDirection;
  cleared?: BankCleared;
  /** Inclusive bounds on `transactionDate`. */
  from?: Date;
  to?: Date;
  sort: BankSort;
}

/** Raw shape of a year/month rollup row. */
interface PeriodRow {
  period: number;
  count: bigint | number | string;
  income: Prisma.Decimal | null;
  expense: Prisma.Decimal | null;
  net: Prisma.Decimal | null;
}

function num(v: bigint | number | string | null | undefined): number {
  if (v === null || v === undefined) return 0;
  return typeof v === "number" ? v : Number(v);
}

function dec(v: Prisma.Decimal | null | undefined): string {
  return (v ?? new Prisma.Decimal(0)).toFixed(2);
}

@Injectable()
export class BankService {
  constructor(private readonly prisma: PrismaService) {}

  private where(p: BankListParams): Prisma.BankTransactionWhereInput {
    const and: Prisma.BankTransactionWhereInput[] = [
      { bankAccountId: p.bankAccountId },
    ];

    if (p.query && p.query.trim()) {
      const q = p.query.trim();
      and.push({
        OR: [
          { concept: { contains: q } },
          { reference: { contains: q } },
          { notes: { contains: q } },
          { amountInWords: { contains: q } },
        ],
      });
    }
    if (p.direction === "income") and.push({ amount: { gt: 0 } });
    if (p.direction === "expense") and.push({ amount: { lt: 0 } });
    if (p.direction === "void") and.push({ amount: 0 });
    if (p.cleared) and.push({ cleared: p.cleared === "cleared" });
    if (p.from || p.to) {
      and.push({
        transactionDate: {
          ...(p.from ? { gte: p.from } : {}),
          ...(p.to ? { lte: p.to } : {}),
        },
      });
    }

    // Never empty: the account clause above is always present, so no read can
    // accidentally span every chequera.
    return { AND: and };
  }

  private orderBy(
    sort: BankSort,
  ): Prisma.BankTransactionOrderByWithRelationInput[] {
    switch (sort) {
      case "date_asc":
        return [{ transactionDate: "asc" }, { reference: "asc" }];
      case "amount_desc":
        return [{ amount: "desc" }];
      case "amount_asc":
        return [{ amount: "asc" }];
      case "reference":
        // `reference` is the cheque number on egresos and the deposit slip on
        // ingresos; it is a string column, so this is a lexical sort.
        return [{ reference: "asc" }];
      default:
        return [{ transactionDate: "desc" }, { reference: "desc" }];
    }
  }

  /** The register itself: every deposit and payment, filterable. */
  async list(params: BankListParams) {
    const where = this.where(params);

    const [total, rows] = await this.prisma.$transaction([
      this.prisma.bankTransaction.count({ where }),
      this.prisma.bankTransaction.findMany({
        where,
        skip: (params.page - 1) * params.pageSize,
        take: params.pageSize,
        orderBy: this.orderBy(params.sort),
        select: {
          id: true,
          transactionDate: true,
          transactionType: true,
          reference: true,
          concept: true,
          amount: true,
          cleared: true,
          transferred: true,
          notes: true,
          amountInWords: true,
          legacySourceTable: true,
          voidedAt: true,
        },
      }),
    ]);

    // Totals cover the whole filtered set, not just the page — the figure staff
    // read off a filtered view ("what did we pay CFE in 2025") has to.
    const totals = await this.totalsFor(where);

    return {
      items: rows.map((r) => ({
        id: r.id,
        transactionDate: r.transactionDate,
        transactionType: r.transactionType,
        reference: r.reference,
        concept: r.concept,
        amount: r.amount,
        direction: directionOf(r.amount),
        cleared: r.cleared,
        transferred: r.transferred,
        notes: r.notes,
        amountInWords: r.amountInWords,
        source: r.legacySourceTable,
        voided: r.voidedAt != null,
      })),
      total,
      page: params.page,
      pageSize: params.pageSize,
      pageCount: Math.ceil(total / params.pageSize),
      totals,
    };
  }

  /** Income / expense / void split over an arbitrary filter. */
  private async totalsFor(where: Prisma.BankTransactionWhereInput) {
    const [income, expense, voided] = await Promise.all([
      this.prisma.bankTransaction.aggregate({
        where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] },
        _sum: { amount: true },
        _count: { _all: true },
      }),
      this.prisma.bankTransaction.aggregate({
        where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] },
        _sum: { amount: true },
        _count: { _all: true },
      }),
      this.prisma.bankTransaction.count({
        where: { AND: [where, { amount: 0 }, NOT_VOIDED] },
      }),
    ]);

    const inSum = income._sum.amount ?? new Prisma.Decimal(0);
    const outSum = expense._sum.amount ?? new Prisma.Decimal(0);

    return {
      income: inSum.toFixed(2),
      incomeCount: income._count._all,
      expense: outSum.toFixed(2),
      expenseCount: expense._count._all,
      net: inSum.plus(outSum).toFixed(2),
      voidCount: voided,
    };
  }

  /** Top-line figures for the bank page header, for one chequera. */
  async stats(bankAccountId: string) {
    const account = { bankAccountId };
    const [count, bounds, pending, transferred, totals] = await Promise.all([
      this.prisma.bankTransaction.count({
        where: { AND: [account, NOT_VOIDED] },
      }),
      this.prisma.bankTransaction.aggregate({
        where: { AND: [account, NOT_VOIDED] },
        _min: { transactionDate: true },
        _max: { transactionDate: true },
      }),
      this.prisma.bankTransaction.count({
        where: { AND: [account, { cleared: false }, NOT_VOIDED] },
      }),
      this.prisma.bankTransaction.count({
        where: { AND: [account, { transferred: true }, NOT_VOIDED] },
      }),
      this.totalsFor(account),
    ]);

    return {
      movements: count,
      firstMovement: bounds._min.transactionDate,
      lastMovement: bounds._max.transactionDate,
      pending,
      transferred,
      ...totals,
    };
  }

  /** Year list for the period filter, newest first, for one chequera. */
  async facets(bankAccountId: string) {
    // Tagged-template `$queryRaw`: the interpolation below is a bound
    // parameter, not string concatenation.
    const years = await this.prisma.$queryRaw<
      { year: number; count: bigint | number | string }[]
    >`
      SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
      FROM bank_transactions
      WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
      GROUP BY year
      ORDER BY year DESC
    `;

    return {
      years: years.map((y) => ({ year: Number(y.year), count: num(y.count) })),
    };
  }

  /**
   * Period rollup for the "Resumen" view: one row per year, plus one row per
   * month when a year is selected.
   *
   * `cumulative` is the running sum of every movement from the start of the
   * register — NOT the bank balance. SCOTHIA carries no opening balance (its
   * `BAN` table holds only the bank's name), so the register starts at zero on
   * its first row in 2013 and the running figure is the net movement since
   * then. Labelled as such in the UI so it is never read as a statement balance.
   *
   * Both rollups take the SAME `bankAccountId`. Scoping only one of them would
   * leave the year list and its month drill-down describing different books —
   * wrong in a way that still looks right.
   */
  async summary(bankAccountId: string, year?: number) {
    const years = await this.prisma.$queryRaw<PeriodRow[]>`
      SELECT
        YEAR(transactionDate)                                AS period,
        COUNT(*)                                             AS count,
        SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END)     AS income,
        SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END)     AS expense,
        SUM(amount)                                          AS net
      FROM bank_transactions
      WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
      GROUP BY period
      ORDER BY period ASC
    `;

    const months = year
      ? await this.prisma.$queryRaw<PeriodRow[]>`
          SELECT
            MONTH(transactionDate)                             AS period,
            COUNT(*)                                           AS count,
            SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END)   AS income,
            SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END)   AS expense,
            SUM(amount)                                        AS net
          FROM bank_transactions
          WHERE YEAR(transactionDate) = ${year}
            AND voidedAt IS NULL
            AND bankAccountId = ${bankAccountId}
          GROUP BY period
          ORDER BY period ASC
        `
      : [];

    // Cumulative across years runs from the first row of the register; the
    // monthly cumulative opens at the selected year's opening figure so the two
    // tables agree.
    let running = new Prisma.Decimal(0);
    const yearRows = years.map((r) => {
      const net = r.net ?? new Prisma.Decimal(0);
      const opening = running;
      running = running.plus(net);
      return {
        period: Number(r.period),
        count: num(r.count),
        income: dec(r.income),
        expense: dec(r.expense),
        net: net.toFixed(2),
        opening: opening.toFixed(2),
        cumulative: running.toFixed(2),
      };
    });

    const opening =
      year === undefined
        ? new Prisma.Decimal(0)
        : new Prisma.Decimal(
            yearRows.find((y) => y.period === year)?.opening ?? "0",
          );

    let monthRunning = opening;
    const monthRows = months.map((r) => {
      const net = r.net ?? new Prisma.Decimal(0);
      monthRunning = monthRunning.plus(net);
      return {
        period: Number(r.period),
        count: num(r.count),
        income: dec(r.income),
        expense: dec(r.expense),
        net: net.toFixed(2),
        cumulative: monthRunning.toFixed(2),
      };
    });

    return {
      year: year ?? null,
      years: yearRows,
      months: monthRows,
      opening: opening.toFixed(2),
    };
  }

  // --- accounts -------------------------------------------------------------

  /**
   * Every chequera, closed ones included — a closed account still has to be
   * selectable to read its history, it just isn't offered for new captures.
   */
  async listAccounts() {
    const rows = await this.prisma.bankAccount.findMany({
      orderBy: [{ active: "desc" }, { label: "asc" }],
      select: {
        id: true,
        label: true,
        currency: true,
        businessLine: true,
        active: true,
        bank: { select: { id: true, name: true, country: true } },
      },
    });
    return rows.map((a) => ({
      id: a.id,
      label: a.label,
      currency: a.currency,
      businessLine: a.businessLine,
      active: a.active,
      bankId: a.bank.id,
      bankName: a.bank.name,
      bankCountry: a.bank.country,
    }));
  }

  async listBanks() {
    return this.prisma.bank.findMany({
      orderBy: { name: "asc" },
      select: { id: true, name: true, country: true },
    });
  }

  /**
   * Resolve an account id from a request, or reject. Every read route funnels
   * through this so a bad/missing id is a 400 rather than a silently empty
   * register that reads as "this account has no movements".
   */
  async requireAccount(bankAccountId: string | undefined) {
    if (!bankAccountId || !bankAccountId.trim())
      throw new BadRequestException("Falta la cuenta bancaria (bankAccountId)");
    const account = await this.prisma.bankAccount.findUnique({
      where: { id: bankAccountId },
      select: { id: true, label: true, currency: true, active: true },
    });
    if (!account)
      throw new NotFoundException(`Cuenta bancaria ${bankAccountId} no existe`);
    return account;
  }

  async createBank(dto: CreateBankDto) {
    return this.prisma.bank.create({
      data: { name: dto.name.trim(), country: dto.country?.trim() || null },
    });
  }

  async updateBank(id: string, dto: UpdateBankDto) {
    await this.getBankOr404(id);
    return this.prisma.bank.update({
      where: { id },
      data: {
        ...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
        ...(dto.country !== undefined
          ? { country: dto.country.trim() || null }
          : {}),
      },
    });
  }

  private async getBankOr404(id: string) {
    const bank = await this.prisma.bank.findUnique({
      where: { id },
      select: { id: true },
    });
    if (!bank) throw new NotFoundException(`Banco ${id} no existe`);
    return bank;
  }

  async createAccount(dto: CreateBankAccountDto) {
    await this.getBankOr404(dto.bankId);
    return this.prisma.bankAccount.create({
      data: {
        bankId: dto.bankId,
        label: dto.label.trim(),
        currency: dto.currency,
        businessLine: dto.businessLine ?? null,
        active: dto.active ?? true,
      },
    });
  }

  /**
   * `currency` is intentionally absent from the update DTO: the movements
   * already booked in this account are denominated in it, so changing it would
   * silently re-denominate history rather than convert it.
   */
  async updateAccount(id: string, dto: UpdateBankAccountDto) {
    await this.requireAccount(id);
    if (dto.bankId !== undefined) await this.getBankOr404(dto.bankId);
    return this.prisma.bankAccount.update({
      where: { id },
      data: {
        ...(dto.bankId !== undefined ? { bankId: dto.bankId } : {}),
        ...(dto.label !== undefined ? { label: dto.label.trim() } : {}),
        ...(dto.businessLine !== undefined
          ? { businessLine: dto.businessLine }
          : {}),
        ...(dto.active !== undefined ? { active: dto.active } : {}),
      },
    });
  }

  // --- writes (append + void) -----------------------------------------------

  async createMovement(dto: CreateBankMovementDto) {
    const date = new Date(dto.transactionDate);
    if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
    const account = await this.requireAccount(dto.bankAccountId);
    if (!account.active)
      throw new BadRequestException(
        `La cuenta "${account.label}" está cerrada; no admite movimientos nuevos.`,
      );
    return this.prisma.bankTransaction.create({
      data: {
        bankAccountId: account.id,
        amount: dto.amount,
        transactionDate: date,
        concept: dto.concept,
        reference: dto.reference,
        transactionType: dto.transactionType,
        cleared: dto.cleared ?? false,
        transferred: dto.transferred ?? false,
        notes: dto.notes,
        amountInWords: dto.amountInWords,
      },
    });
  }

  async voidMovement(id: string, userId: string) {
    const row = await this.prisma.bankTransaction.findUnique({
      where: { id },
      select: { id: true, voidedAt: true },
    });
    if (!row) throw new NotFoundException(`Bank transaction ${id} not found`);
    if (row.voidedAt) throw new BadRequestException("El movimiento ya está anulado");
    return this.prisma.bankTransaction.update({
      where: { id },
      data: { voidedAt: new Date(), voidedById: userId },
    });
  }
}

function directionOf(amount: Prisma.Decimal): BankDirection {
  if (amount.greaterThan(0)) return "income";
  return amount.lessThan(0) ? "expense" : "void";
}
