import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { CreateCustomerDto } from "./create-customer.dto";
import { UpdateCustomerDto } from "./update-customer.dto";
import {
  BALANCE_FORWARD_TYPE,
  notCashJournal,
  PERIOD_TABLE_PREFIX,
} from "../billing/billing.service";

/**
 * Keeps an imported prior period out of the *current* period, NULL-safely.
 *
 * A closed year is imported as its own tagged copy (`datos2@2025`). Below the
 * year start it is history and counts — for the one customer whose newest
 * BALANCE FORWARD lives inside an archive it is the only carry there is, and
 * dropping it understated NUMid 295 by his entire 2025 closing balance. At or
 * above the year start it must go: the archives spill a couple of rows into the
 * following January, and those already sit inside the next year's BALANCE
 * FORWARD, which is the sum of the whole archive.
 *
 * Spelled as a positive OR because `NOT (col LIKE ... AND ...)` evaluates to
 * NULL for an app-captured row (no legacySourceTable), dropping every one.
 */
const archiveIsHistory = (
  yearStart: Date,
): Prisma.TransactionWhereInput => ({
  OR: [
    { legacySourceTable: null },
    { legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
    { transactionDate: { lt: yearStart } },
  ],
});

export interface ListParams {
  query?: string;
  page: number;
  pageSize: number;
  line?: "utility" | "insurance" | "both";
  includeArchived?: boolean;
}

/** Parse an optional ISO date string to a Date (or null to clear it). */
function toDate(v?: string): Date | null | undefined {
  if (v === undefined) return undefined;
  if (v === "" || v === null) return null;
  const d = new Date(v);
  return isNaN(d.getTime()) ? undefined : d;
}

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

  /** Unified customer list with search + business-line filter, paginated. */
  async list({ query, page, pageSize, line, includeArchived }: ListParams) {
    const where: Prisma.CustomerWhereInput = {};

    if (!includeArchived) where.archivedAt = null;

    if (query && query.trim()) {
      const q = query.trim();
      where.OR = [
        { name: { contains: q } },
        { email: { contains: q } },
        { phone: { contains: q } },
        { mobile: { contains: q } },
        { city: { contains: q } },
        { legacyRefs: { some: { legacyId: { contains: q } } } },
      ];
    }
    if (line === "utility") where.properties = { some: {} };
    if (line === "insurance") where.policies = { some: {} };
    if (line === "both") {
      where.properties = { some: {} };
      where.policies = { some: {} };
    }

    const [total, rows] = await this.prisma.$transaction([
      this.prisma.customer.count({ where }),
      this.prisma.customer.findMany({
        where,
        skip: (page - 1) * pageSize,
        take: pageSize,
        // Nameless records last: ordering by name alone floats every
        // "(SIN NOMBRE)" to the top, since "(" sorts before every letter.
        orderBy: [{ nameMissing: "asc" }, { name: "asc" }],
        select: {
          id: true,
          name: true,
          nameSource: true,
          city: true,
          state: true,
          email: true,
          phone: true,
          mobile: true,
          status: true,
          archivedAt: true,
          _count: { select: { properties: true, policies: true, transactions: true } },
        },
      }),
    ]);

    const items = rows.map((r) => ({
      id: r.id,
      name: r.name,
      nameSource: r.nameSource,
      city: r.city,
      state: r.state,
      email: r.email,
      phone: r.phone,
      mobile: r.mobile,
      status: r.status,
      archived: r.archivedAt != null,
      propertyCount: r._count.properties,
      policyCount: r._count.policies,
      transactionCount: r._count.transactions,
      hasUtilities: r._count.properties > 0,
      hasInsurance: r._count.policies > 0,
    }));

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

  /** Full unified customer view: identity + both business lines + ledger. */
  async detail(id: string) {
    // The movement list on the customer file is the same statement the office
    // prints, so it follows the same rule as BillingService.statement: this
    // calendar year, oldest-first. No `take` any more — the cap used to hide
    // the end of a busy customer's year once the order flipped, and a single
    // year is small (365 rows for the heaviest customer in the book).
    const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));

    const customer = await this.prisma.customer.findUnique({
      where: { id },
      include: {
        legacyRefs: true,
        properties: {
          include: { services: true, trustAccount: true, documents: true },
        },
        policies: {
          orderBy: { policyFrom: "desc" },
          include: {
            policyType: true,
            insuranceProvider: true,
            installments: { orderBy: { sequence: "asc" } },
            vehicles: true,
            insuredDrivers: true,
            beneficiaries: true,
            claims: true,
            documents: true,
          },
        },
        transactions: {
          // Archives are excluded by tag, not by date. They are not cleanly
          // bounded — datos2@2024 carries rows dated 2022, 2023, 2025 and one
          // in 2026, datos2@2025 two more — so a date test alone would surface
          // a closed year's rows in the current year's list, duplicating the
          // live ledger's own copy of them for three customers.
          // Archives are kept out by tag, not by date. They are not cleanly
          // bounded — datos2@2025 carries rows dated into 2026 — so a date test
          // alone would surface a closed year's rows in the current year's
          // list. Nothing below yearStart reaches this list anyway, so the
          // window rule reduces to a plain exclusion here.
          where: {
            transactionDate: { gte: yearStart },
            ...archiveIsHistory(yearStart),
          },
          orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
          include: { type: true },
        },
      },
    });

    if (!customer) {
      throw new NotFoundException(`Customer ${id} not found`);
    }

    // Ledger totals per domain + currency (the "one statement across both
    // business lines" payoff), computed in the DB rather than in JS.
    //
    // These have to answer the same question BillingService.statement answers,
    // because this card is titled "Estado de cuenta" and links straight to it —
    // two screens quoting one customer two different balances is worse than
    // either number alone. So it takes the same three rules the statement uses:
    // the balance floor, the cash-source exclusion, and dropping outstanding
    // rows the office has not paid yet.
    //
    // Without the floor these were a raw lifetime sum, double-counting the
    // pre-cutover history each BALANCE FORWARD row already absorbs. Importing
    // prior periods made that visibly worse: for NUMid 501 the tiles read
    // -7,119.29 before the archives landed and -15,270.59 after, against a true
    // -10,715.29 — the difference being exactly the 2024 and 2025 closing
    // balances, added a second time on top of the opening row that contains
    // them.
    const floor = await this.prisma.transaction.findFirst({
      where: {
        customerId: id,
        voidedAt: null,
        type: { nameEn: BALANCE_FORWARD_TYPE },
      },
      orderBy: { transactionDate: "desc" },
      select: { transactionDate: true },
    });

    const summary = await this.prisma.transaction.groupBy({
      by: ["domain", "currency"],
      where: {
        customerId: id,
        voidedAt: null,
        outstanding: false,
        ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
        // The floor alone does not settle the archives: a customer floored by
        // an archive clears it with every row of that archive, and the rows
        // archives spill into the following January clear any floor.
        AND: [archiveIsHistory(yearStart), notCashJournal()],
      },
      _sum: { amount: true },
      _count: { _all: true },
    });

    return {
      ...customer,
      /** Calendar year the movement list covers. */
      transactionYear: yearStart.getUTCFullYear(),
      transactionSummary: summary.map((s) => ({
        domain: s.domain,
        currency: s.currency,
        total: s._sum.amount,
        count: s._count._all,
      })),
    };
  }

  // --- writes ---------------------------------------------------------------

  private toData(dto: CreateCustomerDto | UpdateCustomerDto) {
    // Whitelisted by the DTO already; map the date strings to Date objects.
    const { identificationExpiration, customerSince, ...rest } = dto;
    return {
      ...rest,
      ...(identificationExpiration !== undefined && {
        identificationExpiration: toDate(identificationExpiration),
      }),
      ...(customerSince !== undefined && { customerSince: toDate(customerSince) }),
    };
  }

  async create(dto: CreateCustomerDto) {
    return this.prisma.customer.create({
      // App-created rows: nameMissing false (name is required), no legacy
      // provenance — those columns stay null, marking a native record.
      data: { ...this.toData(dto), name: dto.name, nameMissing: false },
    });
  }

  async update(id: string, dto: UpdateCustomerDto) {
    await this.ensureExists(id);
    return this.prisma.customer.update({ where: { id }, data: this.toData(dto) });
  }

  /** Soft-delete: hide from default lists, keep the row + provenance. */
  async archive(id: string) {
    await this.ensureExists(id);
    return this.prisma.customer.update({
      where: { id },
      data: { archivedAt: new Date() },
    });
  }

  async restore(id: string) {
    await this.ensureExists(id);
    return this.prisma.customer.update({
      where: { id },
      data: { archivedAt: null },
    });
  }

  private async ensureExists(id: string) {
    const found = await this.prisma.customer.findUnique({
      where: { id },
      select: { id: true },
    });
    if (!found) throw new NotFoundException(`Customer ${id} not found`);
  }

  /** Top-line counts for a dashboard header. */
  async stats() {
    const [customers, withUtilities, withInsurance, policies, properties, transactions] =
      await this.prisma.$transaction([
        this.prisma.customer.count(),
        this.prisma.customer.count({ where: { properties: { some: {} } } }),
        this.prisma.customer.count({ where: { policies: { some: {} } } }),
        this.prisma.policy.count(),
        this.prisma.property.count(),
        this.prisma.transaction.count(),
      ]);
    const bothLines = await this.prisma.customer.count({
      where: { properties: { some: {} }, policies: { some: {} } },
    });
    return { customers, withUtilities, withInsurance, bothLines, policies, properties, transactions };
  }
}
