import * as crypto from 'crypto';
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Invoice, InvoiceDocument } from './schemas/invoice.schema';
import { Student } from '../students/schemas/student.schema';
import { Class } from '../classes/schemas/class.schema';
import { CreateInvoiceDto } from './dto/create-invoice.dto';
import { UpdateInvoiceDto } from './dto/update-invoice.dto';
import { FilterInvoicesDto } from './dto/filter-invoices.dto';

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

    constructor(
        @InjectModel(Invoice.name) private invoiceModel: Model<InvoiceDocument>,
        @InjectModel(Student.name) private studentModel: Model<any>,
        @InjectModel(Class.name) private classModel: Model<any>,
    ) {}

    // =====================
    //  Helpers
    // =====================

    private deriveStatus(amount: number, totalPaid: number): 'paid' | 'partial' | 'pending' {
        if (totalPaid >= amount) return 'paid';
        if (totalPaid > 0) return 'partial';
        return 'pending';
    }

    private async generateInvoiceNumber(): Promise<string> {
        const year = new Date().getFullYear();
        const prefix = `INV-${year}-`;

        for (let attempt = 0; attempt < 3; attempt++) {
            const count = await this.invoiceModel.countDocuments({
                invoiceNumber: { $regex: `^${prefix}\\d+$` },
            });

            const seq = count + 1 + attempt;
            const invoiceNumber = `${prefix}${String(seq).padStart(6, '0')}`;
            const exists = await this.invoiceModel.exists({ invoiceNumber });
            if (!exists) return invoiceNumber;
        }

        // Fallback: crypto-based unique invoice number
        return `${prefix}${crypto.randomBytes(4).toString('hex').toUpperCase()}`;
    }

    private async generatePaymentId(): Promise<string> {
        const year = new Date().getFullYear();
        const prefix = `PAY-${year}-`;

        for (let attempt = 0; attempt < 3; attempt++) {
            const count = await this.invoiceModel.countDocuments({
                paymentId: { $regex: `^${prefix}\\d+$` },
            });

            const seq = count + 1 + attempt;
            const paymentId = `${prefix}${String(seq).padStart(6, '0')}`;
            const exists = await this.invoiceModel.exists({ paymentId });
            if (!exists) return paymentId;
        }

        // Fallback: crypto-based unique payment ID
        return `${prefix}${crypto.randomBytes(4).toString('hex').toUpperCase()}`;
    }

    // Sequential receipt number for invoices (distinct from the fee-side receipt
    // space; fee-side uses the same RCPT-YYYY prefix but counts across
    // FeeAssignment + FeePayment). Number collisions across collections are
    // acceptable because each collection enforces its own unique index and
    // verify flows route by prefix. For manual invoices this ensures every
    // paid invoice has a receipt identifier even without a FeePayment record.
    private async generateReceiptNumber(): Promise<string> {
        const year = new Date().getFullYear();
        const prefix = `RCPT-${year}-`;

        for (let attempt = 0; attempt < 3; attempt++) {
            const count = await this.invoiceModel.countDocuments({
                receiptNumber: { $regex: `^${prefix}\\d+$` },
            });

            const seq = count + 1 + attempt;
            const receiptNumber = `${prefix}${String(seq).padStart(6, '0')}`;
            const exists = await this.invoiceModel.exists({ receiptNumber });
            if (!exists) return receiptNumber;
        }

        return `${prefix}${crypto.randomBytes(4).toString('hex').toUpperCase()}`;
    }

    // Auto-generated txn id (TXN-YYYY-NNNNNN) for when admin didn't capture a
    // real bank/UPI/cheque ref. Real refs supplied via DTO bypass this.
    private async generateTransactionId(): Promise<string> {
        const year = new Date().getFullYear();
        const prefix = `TXN-${year}-`;

        for (let attempt = 0; attempt < 3; attempt++) {
            const count = await this.invoiceModel.countDocuments({
                transactionId: { $regex: `^${prefix}\\d+$` },
            });

            const seq = count + 1 + attempt;
            const transactionId = `${prefix}${String(seq).padStart(6, '0')}`;
            const exists = await this.invoiceModel.exists({ transactionId });
            if (!exists) return transactionId;
        }

        return `${prefix}${crypto.randomBytes(4).toString('hex').toUpperCase()}`;
    }

    /** Escape regex special chars for safe MongoDB $regex usage */
    private escapeRegex(str: string): string {
        return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    /** Build a reusable filter query from DTO + academicYearId */
    private buildQuery(filter: FilterInvoicesDto, academicYearId?: string): Record<string, any> {
        const q: any = {};

        if (academicYearId) q.academicYearId = academicYearId;
        if (filter.classId) q.classId = filter.classId;
        if (filter.studentId) q.studentId = filter.studentId;
        if (filter.status) q.status = filter.status;

        if (filter.search) {
            const escaped = this.escapeRegex(filter.search);
            q.$or = [
                { studentName: { $regex: escaped, $options: 'i' } },
                { studentUniqueId: { $regex: escaped, $options: 'i' } },
                { invoiceNumber: { $regex: escaped, $options: 'i' } },
                { paymentId: { $regex: escaped, $options: 'i' } },
            ];
        }

        if (filter.dateFrom || filter.dateTo) {
            q.date = {};
            if (filter.dateFrom) q.date.$gte = new Date(filter.dateFrom);
            if (filter.dateTo) {
                const end = new Date(filter.dateTo);
                end.setHours(23, 59, 59, 999);
                q.date.$lte = end;
            }
        }

        return q;
    }

    // =====================
    //  CRUD
    // =====================

    async create(dto: CreateInvoiceDto, academicYearId?: string) {
        // Look up student
        const student = await this.studentModel
            .findById(dto.studentId, { name: 1, uniqueId: 1, classId: 1 })
            .lean() as any;
        if (!student) throw new NotFoundException('Student not found');

        // Validate student has a class assigned
        if (!student.classId) {
            throw new BadRequestException('Student does not have a class assigned');
        }

        // Validate totalPaid does not exceed amount
        const totalPaid = dto.totalPaid ?? 0;
        if (totalPaid > dto.amount) {
            throw new BadRequestException('Total paid cannot exceed invoice amount');
        }

        // Look up class name
        const cls = await this.classModel
            .findById(student.classId, { name: 1 })
            .lean() as any;

        const invoiceNumber = await this.generateInvoiceNumber();
        const paymentId = totalPaid > 0 ? await this.generatePaymentId() : undefined;
        // Auto-generate receipt + txn identifiers when the invoice is recorded
        // as already paid/partial. Admin-supplied transactionId (real bank/UPI
        // ref) wins; otherwise fall back to an auto-generated TXN.
        const receiptNumber = totalPaid > 0 ? await this.generateReceiptNumber() : undefined;
        const transactionId = totalPaid > 0
            ? (dto.transactionId?.trim() || await this.generateTransactionId())
            : undefined;
        const balance = Math.max(0, dto.amount - totalPaid);
        const status = this.deriveStatus(dto.amount, totalPaid);
        // Route is @Roles(Role.ADMIN)-gated, so caller is always an admin.
        const generatedBy = 'Admin';

        const invoice = await this.invoiceModel.create({
            invoiceNumber,
            paymentId,
            receiptNumber,
            transactionId,
            studentId: dto.studentId,
            studentName: student.name || '',
            studentUniqueId: student.uniqueId || '',
            classId: String(student.classId),
            className: cls?.name || '',
            academicYearId: academicYearId || '',
            amount: dto.amount,
            totalPaid,
            balance,
            status,
            paymentMode: dto.paymentMode,
            date: dto.date ? new Date(dto.date) : new Date(),
            generatedBy,
            isActive: dto.isActive ?? true,
            notes: dto.notes?.trim() || undefined,
        });

        return invoice.toObject();
    }

    async findAll(filter: FilterInvoicesDto, academicYearId?: string) {
        const q = this.buildQuery(filter, academicYearId);

        return this.invoiceModel
            .find(q)
            .sort({ date: -1 })
            .limit(5000)
            .lean({ virtuals: true });
    }

    async findOne(id: string) {
        const invoice = await this.invoiceModel.findById(id).lean({ virtuals: true });
        if (!invoice) throw new NotFoundException('Invoice not found');
        return invoice;
    }

    async update(id: string, dto: UpdateInvoiceDto) {
        const existing = await this.invoiceModel.findById(id).lean();
        if (!existing) throw new NotFoundException('Invoice not found');

        const $set: any = {};

        if (dto.amount !== undefined) $set.amount = dto.amount;
        if (dto.totalPaid !== undefined) $set.totalPaid = dto.totalPaid;
        if (dto.paymentMode !== undefined) $set.paymentMode = dto.paymentMode;
        if (dto.date !== undefined) $set.date = new Date(dto.date);
        if (dto.notes !== undefined) $set.notes = dto.notes.trim();
        if (dto.isActive !== undefined) $set.isActive = dto.isActive;
        // Admin-supplied bank/UPI/cheque ref overrides any prior value.
        if (dto.transactionId !== undefined) {
            const trimmed = dto.transactionId.trim();
            if (trimmed) $set.transactionId = trimmed;
        }

        // Recompute balance and status
        const amount = $set.amount ?? existing.amount;
        const totalPaid = $set.totalPaid ?? existing.totalPaid;

        // Validate totalPaid does not exceed amount
        if (totalPaid > amount) {
            throw new BadRequestException('Total paid cannot exceed invoice amount');
        }

        $set.balance = Math.max(0, amount - totalPaid);
        $set.status = this.deriveStatus(amount, totalPaid);

        // Auto-generate paymentId/receiptNumber/transactionId when payment is
        // first recorded; unset them when totalPaid drops back to 0 (rare).
        const $unset: any = {};
        if (totalPaid > 0 && !existing.paymentId) {
            $set.paymentId = await this.generatePaymentId();
        } else if (totalPaid === 0 && existing.paymentId) {
            $unset.paymentId = 1;
        }
        if (totalPaid > 0 && !existing.receiptNumber) {
            $set.receiptNumber = await this.generateReceiptNumber();
        } else if (totalPaid === 0 && existing.receiptNumber) {
            $unset.receiptNumber = 1;
        }
        if (totalPaid > 0 && !existing.transactionId && !$set.transactionId) {
            $set.transactionId = await this.generateTransactionId();
        } else if (totalPaid === 0 && existing.transactionId && !$set.transactionId) {
            // Skip unset when admin is supplying a new value this update —
            // $set and $unset on the same path would conflict.
            $unset.transactionId = 1;
        }

        const updateOps: any = { $set };
        if (Object.keys($unset).length > 0) updateOps.$unset = $unset;

        // Atomic guard: only set paymentId/receiptNumber if none exists yet
        // (prevents concurrent overwrite). We gate on paymentId as a proxy since
        // all three are generated together at the same transition boundary.
        const updateFilter: any = { _id: id };
        if ($set.paymentId) {
            updateFilter.paymentId = { $exists: false };
        }

        const updated = await this.invoiceModel.findByIdAndUpdate(
            updateFilter,
            updateOps,
            { new: true },
        ).lean({ virtuals: true });

        if (!updated) {
            // Concurrent request already set paymentId — return current state
            const current = await this.invoiceModel.findById(id).lean({ virtuals: true });
            if (!current) throw new NotFoundException('Invoice not found');
            return current;
        }
        return updated;
    }

    async toggleActive(id: string) {
        const updated = await this.invoiceModel.findByIdAndUpdate(
            id,
            [{ $set: { isActive: { $not: '$isActive' } } }],
            { new: true, projection: { isActive: 1 } },
        ).lean();
        if (!updated) throw new NotFoundException('Invoice not found');
        return { _id: id, isActive: (updated as any).isActive };
    }

    // Public receipt verification. Accepts either the auto-generated
    // receiptNumber (RCPT-YYYY-NNNNNN), paymentId (PAY-...), transactionId
    // (TXN-...), or invoiceNumber (INV-...). Returns a public-safe shape so
    // the QR-scan verify page can confirm the invoice without leaking PII.
    async verifyReceipt(lookup: string) {
        const query = {
            $or: [
                { receiptNumber: lookup },
                { paymentId: lookup },
                { transactionId: lookup },
                { invoiceNumber: lookup },
            ],
        };
        const inv = await this.invoiceModel.findOne(query, {
            invoiceNumber: 1, paymentId: 1, receiptNumber: 1, transactionId: 1,
            studentName: 1, studentUniqueId: 1, className: 1,
            feeName: 1, installment: 1,
            amount: 1, fineAmount: 1, totalPaid: 1, balance: 1,
            status: 1, paymentMode: 1, date: 1,
        }).lean();
        if (!inv) return null;
        return {
            receiptNumber: inv.receiptNumber || inv.paymentId || inv.invoiceNumber,
            invoiceNumber: inv.invoiceNumber,
            transactionId: inv.transactionId,
            studentName: inv.studentName,
            studentId: inv.studentUniqueId,
            className: inv.className,
            feeName: inv.feeName || 'Invoice',
            installment: inv.installment || '',
            amount: inv.amount,
            fineAmount: inv.fineAmount || 0,
            totalPaid: inv.totalPaid,
            status: inv.status,
            paidAt: inv.date,
            paymentMethod: inv.paymentMode,
        };
    }

    async delete(id: string) {
        const result = await this.invoiceModel.findByIdAndDelete(id);
        if (!result) throw new NotFoundException('Invoice not found');
        return { deleted: true };
    }

    async exportAll(filter: FilterInvoicesDto, academicYearId?: string) {
        const q = this.buildQuery(filter, academicYearId);

        return this.invoiceModel
            .find(q)
            .sort({ date: -1 })
            .limit(10000)
            .lean({ virtuals: true });
    }

    /**
     * Find-or-create an Invoice tied to a FeeAssignment. Called from the fees flow
     * after each successful FeePayment. On first call creates the Invoice; on subsequent
     * calls updates totalPaid/balance/status/paymentMode to reflect the latest cumulative state.
     */
    async upsertFromFeePayment(input: {
        feeAssignmentId: string;
        studentId: string;
        classId: string;
        academicYearId: string;
        billAmount: number;
        totalPaid: number;
        paymentMethod: string;
        generatedBy: string;
        feeName?: string;
        installment?: string;
        fineAmount?: number;
    }) {
        const billAmount = Math.max(0, input.billAmount);
        const totalPaid = Math.max(0, Math.min(input.totalPaid, billAmount));
        const balance = Math.max(0, billAmount - totalPaid);
        const status = this.deriveStatus(billAmount, totalPaid);
        const paymentMode = input.paymentMethod === 'razorpay'
            ? 'online'
            : (['cash', 'upi', 'cheque', 'bank_transfer', 'online'].includes(input.paymentMethod)
                ? input.paymentMethod as 'cash' | 'upi' | 'cheque' | 'bank_transfer' | 'online'
                : undefined);

        const existing = await this.invoiceModel
            .findOne({ feeAssignmentId: input.feeAssignmentId })
            .lean();

        if (existing) {
            const $set: Record<string, any> = {
                amount: billAmount,
                fineAmount: Math.max(0, input.fineAmount || 0),
                totalPaid,
                balance,
                status,
                date: new Date(),
            };
            if (paymentMode) $set.paymentMode = paymentMode;
            if (input.feeName && !existing.feeName) $set.feeName = input.feeName;
            if (input.installment && !existing.installment) $set.installment = input.installment;

            const updated = await this.invoiceModel.findByIdAndUpdate(
                existing._id,
                { $set },
                { new: true },
            ).lean({ virtuals: true });
            return updated;
        }

        // classId comes from the FeeAssignment (the caller), so invoice creation
        // no longer hinges on the student's current classId — which can be stale
        // after promotion/demotion/deactivation and was previously causing silent
        // invoice-creation skips for partial payments.
        const student = await this.studentModel
            .findById(input.studentId, { name: 1, uniqueId: 1 })
            .lean() as any;
        if (!student) {
            this.logger.warn(`upsertFromFeePayment: student ${input.studentId} not found — invoice skipped for assignment ${input.feeAssignmentId}`);
            return null;
        }

        const cls = await this.classModel
            .findById(input.classId, { name: 1 })
            .lean() as any;

        const invoiceNumber = await this.generateInvoiceNumber();
        const paymentId = totalPaid > 0 ? await this.generatePaymentId() : undefined;

        try {
            const invoice = await this.invoiceModel.create({
                invoiceNumber,
                paymentId,
                feeAssignmentId: input.feeAssignmentId,
                feeName: input.feeName,
                installment: input.installment,
                studentId: input.studentId,
                studentName: student.name || '',
                studentUniqueId: student.uniqueId || '',
                classId: input.classId,
                className: cls?.name || '',
                academicYearId: input.academicYearId,
                amount: billAmount,
                fineAmount: Math.max(0, input.fineAmount || 0),
                totalPaid,
                balance,
                status,
                paymentMode,
                generatedBy: input.generatedBy || 'System',
                isActive: true,
            });
            return invoice.toObject();
        } catch (err: any) {
            // Concurrent upsert — another call already created the invoice for this assignment.
            // Fetch and update it instead.
            if (err?.code === 11000) {
                const concurrent = await this.invoiceModel
                    .findOne({ feeAssignmentId: input.feeAssignmentId })
                    .lean();
                if (concurrent) {
                    const $set: Record<string, any> = {
                        amount: billAmount,
                        fineAmount: Math.max(0, input.fineAmount || 0),
                        totalPaid,
                        balance,
                        status,
                        date: new Date(),
                    };
                    if (paymentMode) $set.paymentMode = paymentMode;
                    return this.invoiceModel.findByIdAndUpdate(
                        concurrent._id,
                        { $set },
                        { new: true },
                    ).lean({ virtuals: true });
                }
            }
            throw err;
        }
    }
}
