import { Injectable } from '@nestjs/common';
import { and, desc, eq, gte, inArray, lte, sql } from 'drizzle-orm';
import { RunInvoiceDto, InvoiceQuerySchema, IssueInvoiceSchema, RecordPaymentSchema, CreateCreditNoteSchema, ChargeQuerySchema, PaginationQueryDto } from '@wms/shared';
import { z } from 'zod';
import Decimal from 'decimal.js';
import { DbService, Tx } from '@/common/db/db.service';
import { invoices, invoiceLines, chargeEvents, creditNotes, storers, billingContracts, tariffs } from '@/db/schema';
import { AuditService } from '@/common/audit/audit.service';
import { NumberingService } from '@/common/numbering/numbering.service';
import { EventBus } from '@/common/events/event-bus.service';
import { MailService } from '@/common/mail/mail.service';
import { requireRequestContext } from '@/common/context/request-context';
import { DomainError, NotFoundError, ValidationError, ErrorCodes } from '@/common/errors/domain-error';
import { D, ZERO, paginate, offsetOf, resolveStorerScope, serialize, addDays } from '@/common/util';
import { DocumentsService } from '@/modules/documents/documents.service';
import { ContractsService } from './contracts.service';
import { StorageBillingService } from './storage-billing.service';

@Injectable()
export class InvoicesService {
  constructor(
    private readonly db: DbService,
    private readonly audit: AuditService,
    private readonly numbering: NumberingService,
    private readonly events: EventBus,
    private readonly mail: MailService,
    private readonly documents: DocumentsService,
    private readonly contracts: ContractsService,
    private readonly storage: StorageBillingService,
  ) {}

  // ---------------- charges ----------------
  listCharges(q: z.infer<typeof ChargeQuerySchema> & PaginationQueryDto) {
    const storerId = resolveStorerScope(q.storerId);
    return this.db.run(async (tx) => {
      const where = and(
        storerId ? eq(chargeEvents.storerId, storerId) : undefined,
        q.warehouseId ? eq(chargeEvents.warehouseId, q.warehouseId) : undefined,
        q.status ? inArray(chargeEvents.status, q.status.split(',') as never) : undefined,
        q.periodKey ? eq(chargeEvents.periodKey, q.periodKey) : undefined,
        q.from ? gte(chargeEvents.chargeDate, q.from) : undefined,
        q.to ? lte(chargeEvents.chargeDate, q.to) : undefined,
        q.group ? eq(tariffs.group, q.group) : undefined,
      );
      const [{ count, total }] = await tx.select({ count: sql<number>`count(*)`.mapWith(Number), total: sql<string>`coalesce(sum(${chargeEvents.amount}),0)` }).from(chargeEvents).innerJoin(tariffs, eq(tariffs.id, chargeEvents.tariffId)).where(where);
      const rows = await tx
        .select({ c: chargeEvents, tariffCode: tariffs.code, group: tariffs.group, storerCode: storers.code })
        .from(chargeEvents)
        .innerJoin(tariffs, eq(tariffs.id, chargeEvents.tariffId))
        .innerJoin(storers, eq(storers.id, chargeEvents.storerId))
        .where(where)
        .orderBy(desc(chargeEvents.chargeDate), desc(chargeEvents.createdAt))
        .limit(q.pageSize)
        .offset(offsetOf(q));
      return { ...paginate(rows.map((r) => ({ ...r.c, tariffCode: r.tariffCode, group: r.group, storerCode: r.storerCode })), count, q), total };
    });
  }

  // ---------------- invoices ----------------
  list(q: z.infer<typeof InvoiceQuerySchema> & PaginationQueryDto) {
    const storerId = resolveStorerScope(q.storerId);
    return this.db.run(async (tx) => {
      const where = and(storerId ? eq(invoices.storerId, storerId) : undefined, q.status ? eq(invoices.status, q.status) : undefined, q.from ? gte(invoices.periodStart, q.from) : undefined, q.to ? lte(invoices.periodEnd, q.to) : undefined);
      const [{ count }] = await tx.select({ count: sql<number>`count(*)`.mapWith(Number) }).from(invoices).where(where);
      const rows = await tx.select({ i: invoices, storerCode: storers.code, storerName: storers.name }).from(invoices).innerJoin(storers, eq(storers.id, invoices.storerId)).where(where).orderBy(desc(invoices.createdAt)).limit(q.pageSize).offset(offsetOf(q));
      return paginate(rows.map((r) => ({ ...r.i, storerCode: r.storerCode, storerName: r.storerName })), count, q);
    });
  }

  get(id: string) {
    return this.db.run((tx) => this.getInTx(tx, id));
  }

  async getInTx(tx: Tx, id: string) {
    const [i] = await tx.select().from(invoices).where(eq(invoices.id, id)).limit(1);
    if (!i) throw new NotFoundError('Invoice', id);
    resolveStorerScope(i.storerId);
    const lines = await tx.select().from(invoiceLines).where(eq(invoiceLines.invoiceId, id)).orderBy(invoiceLines.sortOrder);
    const credits = await tx.select().from(creditNotes).where(eq(creditNotes.invoiceId, id));
    const [storer] = await tx.select().from(storers).where(eq(storers.id, i.storerId)).limit(1);
    const [contract] = await tx.select({ contractNumber: billingContracts.contractNumber, name: billingContracts.name, paymentTermsDays: billingContracts.paymentTermsDays }).from(billingContracts).where(eq(billingContracts.id, i.contractId)).limit(1);
    return serialize({ ...i, lines, creditNotes: credits, storer: { id: storer.id, code: storer.code, name: storer.name, legalName: storer.legalName, taxId: storer.taxId, address: storer.address }, contract });
  }

  /** Builds a DRAFT invoice: storage lines from snapshots + pending charge events grouped per tariff. */
  run(dto: RunInvoiceDto) {
    const ctx = requireRequestContext();
    return this.db.run(async (tx) => {
      const found = dto.contractId ? (await tx.select().from(billingContracts).where(eq(billingContracts.id, dto.contractId)).limit(1))[0] : await this.contracts.activeContract(tx, dto.storerId, dto.periodEnd);
      if (!found) throw new DomainError(ErrorCodes.CONTRACT_MISSING, 'No active contract for the storer in this period');
      if (found.storerId !== dto.storerId) throw new ValidationError('Contract does not belong to this storer');
      // Serialise invoice runs per contract: two parallel runs for the same period would both pass the overlap check.
      const [contract] = await tx.select().from(billingContracts).where(eq(billingContracts.id, found.id)).for('update');
      const [overlap] = await tx.select({ id: invoices.id, invoiceNumber: invoices.invoiceNumber }).from(invoices).where(and(eq(invoices.storerId, dto.storerId), sql`${invoices.status} <> 'CANCELLED'`, lte(invoices.periodStart, dto.periodEnd), gte(invoices.periodEnd, dto.periodStart))).limit(1);
      if (overlap) throw new DomainError(ErrorCodes.DUPLICATE, `Invoice ${overlap.invoiceNumber} already covers part of this period; cancel it to re-run`, 409, { invoiceId: overlap.id });

      const invoiceNumber = await this.numbering.next('INV', tx);
      const [inv] = await tx.insert(invoices).values({ tenantId: ctx.tenantId!, storerId: dto.storerId, contractId: contract.id, invoiceNumber, periodStart: dto.periodStart, periodEnd: dto.periodEnd, currency: contract.currency, notes: dto.notes, createdById: ctx.userId }).returning();

      let sort = 0;
      let subtotal = ZERO;
      let taxTotal = ZERO;
      const taxRate = D(contract.taxRatePct);
      const addLine = async (l: { tariffId: string | null; group: (typeof tariffs.$inferSelect)['group']; description: string; quantity: Decimal; unit: (typeof tariffs.$inferSelect)['chargeUnit']; rate: Decimal; amount: Decimal; taxable: boolean; detail?: Record<string, unknown>; chargeEventIds?: string[] }) => {
        sort += 10;
        const amount = l.amount.toDecimalPlaces(3);
        const tax = l.taxable ? amount.mul(taxRate).div(100).toDecimalPlaces(3) : ZERO;
        const [row] = await tx.insert(invoiceLines).values({ tenantId: ctx.tenantId!, invoiceId: inv.id, tariffId: l.tariffId, group: l.group, description: l.description, quantity: l.quantity, unit: l.unit, rate: l.rate, amount, taxRatePct: l.taxable ? taxRate : ZERO, taxAmount: tax, sortOrder: sort, detail: l.detail ?? {}, chargeEventIds: l.chargeEventIds ?? [] }).returning();
        subtotal = subtotal.plus(amount);
        taxTotal = taxTotal.plus(tax);
        return row;
      };

      // 1. storage
      if (dto.computeStorage) {
        const storageLines = await this.storage.computePeriod(tx, contract, dto.periodStart, dto.periodEnd, dto.warehouseIds);
        for (const s of storageLines) {
          const t = s.tariffId ? (await tx.select().from(tariffs).where(eq(tariffs.id, s.tariffId)).limit(1))[0] : null;
          await addLine({ tariffId: s.tariffId || null, group: 'STORAGE', description: s.description, quantity: s.quantity, unit: s.unit as never, rate: s.rate, amount: s.amount, taxable: t?.taxable ?? true, detail: s.detail });
        }
      }

      // 2. captured charges, grouped per tariff
      if (dto.includePendingCharges) {
        const pending = await tx
          .select({ c: chargeEvents, t: tariffs })
          .from(chargeEvents)
          .innerJoin(tariffs, eq(tariffs.id, chargeEvents.tariffId))
          .where(and(eq(chargeEvents.storerId, dto.storerId), eq(chargeEvents.status, 'PENDING'), gte(chargeEvents.chargeDate, dto.periodStart), lte(chargeEvents.chargeDate, dto.periodEnd), dto.warehouseIds?.length ? inArray(chargeEvents.warehouseId, dto.warehouseIds) : undefined))
          .orderBy(tariffs.group, tariffs.sortOrder, chargeEvents.chargeDate)
          .for('update', { of: chargeEvents });
        const groups = new Map<string, typeof pending>();
        for (const p of pending) groups.set(p.t.id, [...(groups.get(p.t.id) ?? []), p]);
        for (const [tariffId, rows] of groups) {
          const t = rows[0].t;
          const qty = rows.reduce((a, r) => a.plus(r.c.quantity), ZERO);
          const amount = rows.reduce((a, r) => a.plus(r.c.amount), ZERO);
          const minApplied = rows.some((r) => (r.c.detail as { minimumApplied?: boolean }).minimumApplied);
          const line = await addLine({ tariffId, group: t.group, description: minApplied ? `${t.description} (minimum charge ${t.minimumCharge} applied)` : t.description, quantity: qty, unit: t.chargeUnit, rate: qty.gt(0) ? amount.div(qty).toDecimalPlaces(6) : D(t.rate), amount, taxable: t.taxable, detail: { events: rows.map((r) => ({ date: r.c.chargeDate, ref: r.c.refNumber, qty: r.c.quantity.toString(), amount: r.c.amount.toString() })) }, chargeEventIds: rows.map((r) => r.c.id) });
          await tx.update(chargeEvents).set({ status: 'INVOICED', invoiceLineId: line.id }).where(inArray(chargeEvents.id, rows.map((r) => r.c.id)));
        }
      }

      // 3. minimum monthly top-up
      if (contract.minimumMonthly && subtotal.lt(contract.minimumMonthly)) {
        const topUp = D(contract.minimumMonthly).minus(subtotal);
        await addLine({ tariffId: null, group: 'ADMIN', description: `Minimum monthly charge adjustment (minimum ${contract.minimumMonthly} ${contract.currency})`, quantity: D(1), unit: 'FLAT', rate: topUp, amount: topUp, taxable: true });
      }

      const total = subtotal.plus(taxTotal);
      await tx.update(invoices).set({ subtotal, taxTotal, total, dueAt: addDays(dto.periodEnd, contract.paymentTermsDays) }).where(eq(invoices.id, inv.id));
      await this.audit.log({ action: 'invoice.run', entityType: 'invoice', entityId: inv.id, after: { ...dto, subtotal: subtotal.toString(), total: total.toString() } }, tx);
      return this.getInTx(tx, inv.id);
    });
  }

  issue(id: string, dto: z.infer<typeof IssueInvoiceSchema>) {
    return this.db.run(async (tx) => {
      const [inv] = await tx.select().from(invoices).where(eq(invoices.id, id)).limit(1).for('update');
      if (!inv) throw new NotFoundError('Invoice', id);
      if (inv.status !== 'DRAFT') throw new DomainError(ErrorCodes.INVALID_STATE, `Invoice is ${inv.status}`);
      const full = await this.getInTx(tx, id);
      const issuedAt = new Date();
      const doc = await this.documents.generate(tx, { type: 'INVOICE', number: inv.invoiceNumber, refType: 'INVOICE', refId: id, data: { invoice: { ...full, issuedAt, dueAt: dto.dueAt ?? inv.dueAt }, storer: (full as { storer: unknown }).storer, lines: (full as { lines: unknown[] }).lines, contractNumber: (full as { contract: { contractNumber: string } }).contract?.contractNumber } });
      const [row] = await tx.update(invoices).set({ status: 'ISSUED', issuedAt, dueAt: dto.dueAt ?? inv.dueAt, documentId: doc.id }).where(eq(invoices.id, id)).returning();
      if (dto.sendTo?.length) {
        await this.mail.queue({ to: dto.sendTo, subject: `Invoice ${inv.invoiceNumber} for ${inv.periodStart} to ${inv.periodEnd}`, bodyHtml: `<p>Please find attached invoice ${inv.invoiceNumber} for the period ${inv.periodStart} to ${inv.periodEnd}. Total ${row.total} ${inv.currency}, due ${row.dueAt}.</p>`, attachments: [{ documentId: doc.id }], refType: 'INVOICE', refId: id }, tx);
        await tx.update(invoices).set({ status: 'SENT', sentAt: new Date() }).where(eq(invoices.id, id));
      }
      await this.audit.log({ action: 'invoice.issue', entityType: 'invoice', entityId: id, after: dto }, tx);
      await this.events.publish('invoice.issued', { invoiceId: id, invoiceNumber: inv.invoiceNumber, total: row.total.toString(), currency: inv.currency }, tx, { storerId: inv.storerId });
      return this.getInTx(tx, id);
    });
  }

  cancel(id: string, reason: string) {
    return this.db.run(async (tx) => {
      const [inv] = await tx.select().from(invoices).where(eq(invoices.id, id)).limit(1).for('update');
      if (!inv) throw new NotFoundError('Invoice', id);
      if (['PAID', 'PARTIALLY_PAID', 'CANCELLED'].includes(inv.status)) throw new DomainError(ErrorCodes.INVALID_STATE, `Invoice is ${inv.status}`);
      const lines = await tx.select().from(invoiceLines).where(eq(invoiceLines.invoiceId, id));
      const eventIds = lines.flatMap((l) => l.chargeEventIds);
      if (eventIds.length) await tx.update(chargeEvents).set({ status: 'PENDING', invoiceLineId: null }).where(inArray(chargeEvents.id, eventIds));
      const [row] = await tx.update(invoices).set({ status: 'CANCELLED', notes: `${inv.notes ?? ''}\n[Cancelled] ${reason}`.trim() }).where(eq(invoices.id, id)).returning();
      await this.audit.log({ action: 'invoice.cancel', entityType: 'invoice', entityId: id, after: { reason, releasedCharges: eventIds.length } }, tx);
      return serialize(row);
    });
  }

  recordPayment(id: string, dto: z.infer<typeof RecordPaymentSchema>) {
    return this.db.run(async (tx) => {
      const [inv] = await tx.select().from(invoices).where(eq(invoices.id, id)).limit(1).for('update');
      if (!inv) throw new NotFoundError('Invoice', id);
      if (!['ISSUED', 'SENT', 'PARTIALLY_PAID', 'DISPUTED'].includes(inv.status)) throw new DomainError(ErrorCodes.INVALID_STATE, `Invoice is ${inv.status}`);
      const paid = D(inv.paidAmount).plus(dto.amount);
      const credits = await tx.select({ s: sql<string>`coalesce(sum(${creditNotes.amount}),0)` }).from(creditNotes).where(eq(creditNotes.invoiceId, id));
      const due = D(inv.total).minus(credits[0].s);
      const [row] = await tx.update(invoices).set({ paidAmount: paid, status: paid.gte(due) ? 'PAID' : 'PARTIALLY_PAID', paidAt: paid.gte(due) ? new Date() : null }).where(eq(invoices.id, id)).returning();
      await this.audit.log({ action: 'invoice.payment', entityType: 'invoice', entityId: id, after: dto }, tx);
      return serialize(row);
    });
  }

  creditNote(id: string, dto: z.infer<typeof CreateCreditNoteSchema>) {
    const ctx = requireRequestContext();
    return this.db.run(async (tx) => {
      const [inv] = await tx.select().from(invoices).where(eq(invoices.id, id)).limit(1).for('update');
      if (!inv) throw new NotFoundError('Invoice', id);
      if (['DRAFT', 'CANCELLED'].includes(inv.status)) throw new DomainError(ErrorCodes.INVALID_STATE, `Invoice is ${inv.status}`);
      const [{ credited }] = await tx.select({ credited: sql<string>`coalesce(sum(${creditNotes.amount}),0)` }).from(creditNotes).where(eq(creditNotes.invoiceId, id));
      const outstanding = D(inv.total).minus(credited);
      if (D(dto.amount).gt(outstanding)) throw new ValidationError(`Credit note exceeds the remaining invoice amount (${outstanding.toFixed(3)})`);
      const creditNumber = await this.numbering.next('CRN', tx);
      const [cn] = await tx.insert(creditNotes).values({ tenantId: ctx.tenantId!, invoiceId: id, creditNumber, amount: D(dto.amount), reason: dto.reason, createdById: ctx.userId }).returning();
      await this.audit.log({ action: 'invoice.credit_note', entityType: 'credit_note', entityId: cn.id, after: dto }, tx);
      return serialize(cn);
    });
  }

  dispute(id: string, reason: string) {
    return this.db.run(async (tx) => {
      const [row] = await tx.update(invoices).set({ status: 'DISPUTED', notes: sql`coalesce(${invoices.notes},'') || ${'\n[Dispute] ' + reason}` }).where(and(eq(invoices.id, id), inArray(invoices.status, ['ISSUED', 'SENT', 'PARTIALLY_PAID']))).returning();
      if (!row) throw new DomainError(ErrorCodes.INVALID_STATE, 'Invoice cannot be disputed in its current state');
      await this.audit.log({ action: 'invoice.dispute', entityType: 'invoice', entityId: id, after: { reason } }, tx);
      return serialize(row);
    });
  }
}
