import {
    BadRequestException,
    Injectable,
    Logger,
    NotFoundException,
} from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import * as crypto from 'crypto';
import * as fs from 'fs/promises';
import * as path from 'path';
import { randomBytes } from 'crypto';
import Razorpay from 'razorpay';
import { timingSafeEqual } from '../common/utils/timing-safe-equal';
import { toFullUrl } from '../common/helpers/url.helper';

import { FeeStructure, FeeStructureDocument } from './schemas/fee-structure.schema';
import { FeeAssignment, FeeAssignmentDocument } from './schemas/fee-assignment.schema';
import { FeePayment, FeePaymentDocument } from './schemas/fee-payment.schema';
import { Student, StudentDocument } from '../students/schemas/student.schema';
import { AcademicYear, AcademicYearDocument } from '../academic-year/academic-year.schema';
import { InvoicesService } from '../invoices/invoices.service';

import { CreateFeeStructureDto } from './dto/create-fee-structure.dto';
import { UpdateFeeStructureDto } from './dto/update-fee-structure.dto';
import { RecordPaymentDto } from './dto/record-payment.dto';
import { FilterAssignmentsDto } from './dto/filter-assignments.dto';
import { ApplyDiscountDto } from './dto/apply-discount.dto';
import { UpdateDiscountDto } from './dto/update-discount.dto';
import {
    generateMonthlyDueDates,
    generateQuarterlyDueDates,
    clampDayToMonth,
    buildQuarterLabels,
    yearForMonthInAY,
} from './fees.installment-dates';

const MONTHS = [
    '', 'January', 'February', 'March', 'April', 'May', 'June',
    'July', 'August', 'September', 'October', 'November', 'December',
];


@Injectable()
export class FeesService {
    private readonly logger = new Logger(FeesService.name);
    private razor: Razorpay | null = null;
    private razorpayKeyId: string;
    private razorpayKeySecret: string;
    private razorpayWebhookSecret: string;

    constructor(
        @InjectModel(FeeStructure.name) private structureModel: Model<FeeStructureDocument>,
        @InjectModel(FeeAssignment.name) private assignmentModel: Model<FeeAssignmentDocument>,
        @InjectModel(Student.name) private studentModel: Model<StudentDocument>,
        @InjectModel(AcademicYear.name) private academicYearModel: Model<AcademicYearDocument>,
        @InjectModel(FeePayment.name) private feePaymentModel: Model<FeePaymentDocument>,
        private readonly invoicesService: InvoicesService,
    ) {
        this.razorpayKeyId = process.env.RAZORPAY_KEY_ID || '';
        this.razorpayKeySecret = process.env.RAZORPAY_KEY_SECRET || '';
        this.razorpayWebhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET || '';
    }

    /** Lazily initializes Razorpay SDK — throws only when online payment is actually attempted */
    private getRazorpay(): Razorpay {
        if (this.razor) return this.razor;
        if (!this.razorpayKeyId || !this.razorpayKeySecret) {
            throw new BadRequestException(
                'Online payments are not configured. Set RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET.',
            );
        }
        this.razor = new Razorpay({ key_id: this.razorpayKeyId, key_secret: this.razorpayKeySecret });
        return this.razor;
    }

    /* =====================
       Fee Structures (Admin)
    ===================== */

    async createStructure(dto: CreateFeeStructureDto) {
        // Prevent duplicate fee structures with overlapping classes
        const classOverlap = dto.classIds.includes('all')
            ? {} // 'all' conflicts with any existing structure
            : { $or: [{ classIds: { $in: dto.classIds } }, { classIds: 'all' }] };
        const existing = await this.structureModel.findOne({
            name: dto.name.trim(),
            academicYearId: dto.academicYearId,
            feeType: dto.feeType,
            ...classOverlap,
        }).lean();
        if (existing) {
            throw new BadRequestException(
                'A fee structure with this name, type, and overlapping class already exists.',
            );
        }

        const structure = await this.structureModel.create({
            name: dto.name.trim(),
            feeType: dto.feeType,
            academicYearId: dto.academicYearId,
            amount: dto.amount,
            classIds: dto.classIds,
            dueMonth: dto.dueMonth ?? 1,
            dueDay: dto.dueDay,
            startDate: dto.startDate ? new Date(dto.startDate) : undefined,
            endDate: dto.endDate ? new Date(dto.endDate) : undefined,
            enableSubFees: dto.enableSubFees ?? false,
            subFees: dto.subFees ?? [],
            lateFine: dto.lateFine ?? { enabled: false, type: 'per_day', amountPerDay: 0, percentage: 0, slabs: [] },
            isActive: dto.isActive ?? true,
        });

        // Auto-generate assignments for applicable students
        const assignmentStats = await this.generateAssignments(
            structure.toObject() as FeeStructure & { _id: string },
        );

        return {
            ...structure.toObject(),
            assignmentStats,
        };
    }

    async getStructures(academicYearId?: string, classId?: string, activeOnly?: boolean) {
        const q: any = {};
        if (academicYearId) q.academicYearId = academicYearId;
        if (classId) q.$or = [{ classIds: classId }, { classIds: 'all' }];
        if (activeOnly) q.isActive = true;
        return this.structureModel.find(q).sort({ createdAt: -1 }).limit(500).lean();
    }

    async getStructure(id: string) {
        const s = await this.structureModel.findById(id).lean();
        if (!s) throw new NotFoundException('Fee structure not found');
        return s;
    }

    async updateStructure(id: string, dto: UpdateFeeStructureDto) {
        // Block mutation of structural identity fields — changing these would orphan existing assignments
        if (dto.academicYearId || dto.feeType) {
            throw new BadRequestException(
                'Cannot change academic year or fee type. Delete and recreate the structure instead.',
            );
        }

        // Block update if any assignment is already paid or partially paid
        const paidOrPartialCount = await this.assignmentModel.countDocuments({
            feeStructureId: id,
            status: { $in: ['paid', 'partial'] },
        });
        if (paidOrPartialCount > 0) {
            throw new BadRequestException(
                'Cannot update fee structure — some students have already paid.',
            );
        }

        // Explicitly strip identity fields that must not be mutated
        const { academicYearId: _ay, feeType: _ft, ...safeDto } = dto as any;
        // Explicitly coerce date strings to Date objects so Mongoose stores them
        // as BSON dates, not strings (defensive — schema casting usually handles
        // this but relies on strict schema types being enforced).
        if (safeDto.startDate !== undefined) {
            safeDto.startDate = safeDto.startDate ? new Date(safeDto.startDate) : undefined;
        }
        if (safeDto.endDate !== undefined) {
            safeDto.endDate = safeDto.endDate ? new Date(safeDto.endDate) : undefined;
        }
        const updated = await this.structureModel
            .findByIdAndUpdate(id, { $set: safeDto }, { new: true, runValidators: true })
            .lean();
        if (!updated) throw new NotFoundException('Fee structure not found');

        // Reconcile unpaid assignments with the updated structure. Upserts
        // preserve each row's status/paidAmount; paid/partial rows are left
        // untouched (locked historical state).
        await this.regenerateUnpaidAssignments(id);

        return updated;
    }

    async deleteStructure(id: string) {
        const structure = await this.structureModel.findById(id).lean();
        if (!structure) throw new NotFoundException('Fee structure not found');

        const paidOrPartialCount = await this.assignmentModel.countDocuments({
            feeStructureId: id,
            status: { $in: ['paid', 'partial'] },
        });
        if (paidOrPartialCount > 0) {
            throw new BadRequestException(
                'Cannot delete fee structure — some students have already paid.',
            );
        }

        // Delete only unpaid assignments first — if a payment sneaked in between the check
        // and here, that paid/partial assignment will survive (not matched by status filter).
        await this.assignmentModel.deleteMany({ feeStructureId: id, status: { $nin: ['paid', 'partial'] } });

        // Re-check: if any paid/partial assignments now exist (race), abort structure deletion
        const racePaid = await this.assignmentModel.countDocuments({ feeStructureId: id, status: { $in: ['paid', 'partial'] } });
        if (racePaid > 0) {
            throw new BadRequestException(
                'A payment was recorded while deleting. The fee structure has been preserved.',
            );
        }

        await this.structureModel.findByIdAndDelete(id);
        return { deleted: true };
    }

    async duplicateStructure(sourceId: string, targetAcademicYearId: string) {
        const source = await this.structureModel.findById(sourceId).lean();
        if (!source) throw new NotFoundException('Source fee structure not found');

        // Block same-year copy
        if (targetAcademicYearId === source.academicYearId) {
            throw new BadRequestException('Cannot duplicate to the same academic year');
        }

        // Validate target AY exists
        const targetAy = await this.academicYearModel.findById(targetAcademicYearId).lean();
        if (!targetAy) throw new NotFoundException('Target academic year not found');

        // Check for duplicate (same logic as createStructure)
        const classOverlap = source.classIds.includes('all')
            ? {}
            : { $or: [{ classIds: { $in: source.classIds } }, { classIds: 'all' }] };
        const existing = await this.structureModel.findOne({
            name: source.name,
            academicYearId: targetAcademicYearId,
            feeType: source.feeType,
            ...classOverlap,
        }).lean();
        if (existing) {
            throw new BadRequestException(
                'A fee structure with this name, type, and overlapping class already exists in the target year.',
            );
        }

        const newStructure = await this.structureModel.create({
            name: source.name,
            feeType: source.feeType,
            academicYearId: targetAcademicYearId,
            amount: source.amount,
            classIds: source.classIds,
            dueMonth: source.dueMonth,
            dueDay: source.dueDay,
            enableSubFees: source.enableSubFees,
            subFees: source.subFees,
            lateFine: source.lateFine,
            isActive: true,
        });

        // Auto-generate assignments for students in the target AY
        const { docsCreated, studentCount } = await this.generateAssignments(
            newStructure.toObject() as FeeStructure & { _id: string },
        );

        return {
            structure: newStructure.toObject(),
            assignmentsGenerated: docsCreated,
            studentCount,
        };
    }

    /* =====================
       Discounts
    ===================== */

    /** Returns the active discount amount (0 if expired or no discount).
     *  Once a partial payment has been made, the discount is locked in — expiry is ignored
     *  to prevent the remaining balance from inflating after the student already paid. */
    private getActiveDiscountAmount(assignment: any): number {
        if (!assignment.discountType || !assignment.discountAmount) return 0;
        // Lock discount once any partial payment has been recorded
        if ((assignment.paidAmount || 0) > 0) return assignment.discountAmount || 0;
        if (assignment.discountValidUntil && new Date(assignment.discountValidUntil) < new Date()) return 0;
        return assignment.discountAmount || 0;
    }

    /** Calculates the resolved discount amount from type+value */
    private calculateDiscountAmount(type: 'amount' | 'percentage', value: number, feeAmount: number): number {
        if (type === 'amount') return Math.min(value, feeAmount);
        return Math.round(feeAmount * Math.min(value, 100) / 100);
    }

    /** Enriches assignments with computed fields (discountActive, netPayable, collectedByName) */
    private async enrichAssignments(assignments: any[]): Promise<any[]> {
        return assignments.map((a) => {
            const activeDiscount = this.getActiveDiscountAmount(a);
            const discountActive = activeDiscount > 0;
            let netPayable: number;
            if (a.status === 'paid') {
                netPayable = 0;
            } else {
                netPayable = a.amount + (a.fineAmount || 0) - activeDiscount - (a.paidAmount || 0);
            }
            return {
                ...a,
                discountActive,
                netPayable: Math.max(0, netPayable),
                // Offline payments are admin-only; online (razorpay) has no collectedBy
                // so the frontend falls back to 'Online' based on paymentMethod.
                collectedByName: a.collectedBy ? 'Admin' : undefined,
            };
        });
    }

    async applyDiscount(assignmentId: string, dto: ApplyDiscountDto, adminId: string) {
        // Validate percentage max
        if (dto.discountType === 'percentage' && dto.discountValue > 100) {
            throw new BadRequestException('Percentage discount cannot exceed 100%');
        }

        // Validate validity date if provided
        if (dto.discountValidUntil) {
            const validUntil = new Date(dto.discountValidUntil);
            if (isNaN(validUntil.getTime())) throw new BadRequestException('Invalid validity date');
            if (validUntil < new Date()) throw new BadRequestException('Validity date must be in the future');
        }

        const assignment = await this.assignmentModel.findOne({ _id: assignmentId, status: { $in: ['pending', 'partial'] } }).lean();
        if (!assignment) throw new NotFoundException('Unpaid fee assignment not found');

        const discountAmount = this.calculateDiscountAmount(dto.discountType, dto.discountValue, assignment.amount);

        const updated = await this.assignmentModel.findOneAndUpdate(
            { _id: assignmentId, status: { $in: ['pending', 'partial'] } },
            {
                $set: {
                    discountType: dto.discountType,
                    discountValue: dto.discountValue,
                    discountAmount,
                    discountReason: dto.discountReason || undefined,
                    discountValidUntil: dto.discountValidUntil ? new Date(dto.discountValidUntil) : undefined,
                    discountAppliedBy: adminId,
                },
            },
            { new: true },
        ).lean();

        if (!updated) throw new BadRequestException('Assignment was paid while applying discount');
        const [enriched] = await this.enrichAssignments([updated]);
        return enriched;
    }

    async updateDiscount(assignmentId: string, dto: UpdateDiscountDto, adminId: string) {
        const assignment = await this.assignmentModel.findOne({ _id: assignmentId, status: { $in: ['pending', 'partial'] } }).lean();
        if (!assignment) throw new NotFoundException('Unpaid fee assignment not found');
        if (!assignment.discountType) throw new BadRequestException('No discount exists on this assignment');

        // Require at least one field
        if (!dto.discountType && !dto.discountValue && !dto.discountReason && dto.discountValidUntil === undefined) {
            throw new BadRequestException('At least one field must be provided');
        }

        const $set: any = { discountAppliedBy: adminId };

        const effectiveType = dto.discountType || assignment.discountType!;
        const effectiveValue = dto.discountValue ?? assignment.discountValue!;

        // Validate percentage max using effective values (covers partial updates)
        if (effectiveType === 'percentage' && effectiveValue > 100) {
            throw new BadRequestException('Percentage discount cannot exceed 100%');
        }

        if (dto.discountType) $set.discountType = dto.discountType;
        if (dto.discountValue !== undefined) $set.discountValue = dto.discountValue;
        if (dto.discountReason !== undefined) $set.discountReason = dto.discountReason;

        // Recalculate discount amount if type or value changed
        if (dto.discountType || dto.discountValue !== undefined) {
            $set.discountAmount = this.calculateDiscountAmount(effectiveType, effectiveValue, assignment.amount);
        }

        // Handle validity date: null = remove expiry, string = set new date
        const $unset: Record<string, 1> = {};
        if (dto.discountValidUntil === null) {
            $unset.discountValidUntil = 1;
        } else if (dto.discountValidUntil) {
            const validUntil = new Date(dto.discountValidUntil);
            if (isNaN(validUntil.getTime())) throw new BadRequestException('Invalid validity date');
            if (validUntil < new Date()) throw new BadRequestException('Validity date must be in the future');
            $set.discountValidUntil = validUntil;
        }

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

        const updated = await this.assignmentModel.findOneAndUpdate(
            { _id: assignmentId, status: { $in: ['pending', 'partial'] } },
            updateOp,
            { new: true },
        ).lean();

        if (!updated) throw new BadRequestException('Assignment was paid while updating discount');
        const [enriched] = await this.enrichAssignments([updated]);
        return enriched;
    }

    async removeDiscount(assignmentId: string) {
        const assignment = await this.assignmentModel.findOne({ _id: assignmentId, status: { $in: ['pending', 'partial'] } }).lean();
        if (!assignment) throw new NotFoundException('Unpaid fee assignment not found');

        const updated = await this.assignmentModel.findOneAndUpdate(
            { _id: assignmentId, status: { $in: ['pending', 'partial'] } },
            {
                $unset: {
                    discountType: 1,
                    discountValue: 1,
                    discountReason: 1,
                    discountValidUntil: 1,
                    discountAppliedBy: 1,
                },
                $set: { discountAmount: 0 },
            },
            { new: true },
        ).lean();

        if (!updated) throw new BadRequestException('Assignment was paid while removing discount');
        const [enriched] = await this.enrichAssignments([updated]);
        return enriched;
    }

    /* =====================
       Assignments
    ===================== */

    async getAssignments(filter: FilterAssignmentsDto) {
        const q: any = {};
        if (filter.academicYearId) q.academicYearId = filter.academicYearId;
        if (filter.classId) q.classId = filter.classId;
        if (filter.studentId) q.studentId = filter.studentId;
        if (filter.feeStructureId) q.feeStructureId = filter.feeStructureId;
        if (filter.status) {
            if (filter.status === 'overdue') {
                q.status = { $in: ['pending', 'partial'] };
                q.dueDate = { $lt: new Date() };
            } else if (filter.status === 'pending') {
                q.status = 'pending';
                q.dueDate = { $gte: new Date() };
            } else {
                q.status = filter.status;
            }
        }

        const sortOrder = filter.status === 'paid' ? { paidAt: -1 } : { dueDate: 1 };
        const assignments = await this.assignmentModel.find(q).sort(sortOrder as any).limit(5000).lean();
        const withFines = await this.recalculateFines(assignments);

        // Enrich with discount + admin name fields
        const enriched = await this.enrichAssignments(withFines);

        // Populate student names for admin view
        const studentIds = [...new Set(enriched.map((a) => a.studentId))];
        if (studentIds.length > 0) {
            const students = await this.studentModel
                .find({ _id: { $in: studentIds } }, { name: 1, uniqueId: 1, rollNumber: 1 })
                .lean();
            const studentMap = new Map(students.map((s) => [String(s._id), s]));
            return enriched.map((a) => {
                const stu = studentMap.get(a.studentId);
                return { ...a, studentName: stu?.name, studentUniqueId: (stu as any)?.uniqueId, studentRollNumber: stu?.rollNumber };
            });
        }

        return enriched;
    }

    async getStudentDashboard(studentId: string, academicYearId?: string) {
        const student = await this.studentModel.findById(studentId, {
            name: 1, classId: 1, academicYearId: 1, rollNumber: 1,
            sectionId: 1, documents: 1, profileImageUrl: 1, uniqueId: 1,
            guardianName: 1, father: 1, mother: 1, phone: 1, email: 1,
        }).lean();
        if (!student) throw new NotFoundException('Student not found');

        const ayId = academicYearId || student.academicYearId;

        // Auto-sync: create any missing fee assignments for this student
        if (student.classId) {
            await this.syncStudentAssignments(studentId, String(student.classId), ayId);
        }

        const raw = await this.assignmentModel
            .find({ studentId, academicYearId: ayId })
            .sort({ dueDate: 1 })
            .lean();

        const withFines = await this.recalculateFines(raw);
        const assignments = await this.enrichAssignments(withFines);

        const totalFee = assignments.reduce((s, a) => s + a.amount + (a.fineAmount || 0), 0);

        const totalPaid = assignments.reduce((s, a) => {
            if (a.status === 'paid') {
                const pa = a.paidAmount || 0;
                if (pa > 0) return s + pa;
                return s + a.amount + (a.fineAmount || 0) - (a.discountAmount || 0);
            }
            return s + (a.paidAmount || 0);
        }, 0);

        const paidDiscount = assignments
            .filter((a) => a.status === 'paid')
            .reduce((s, a) => s + (a.discountAmount || 0), 0);
        const totalDiscount = assignments
            .filter((a) => a.status !== 'paid')
            .reduce((s, a) => s + (a.discountActive ? (a.discountAmount || 0) : 0), 0);
        const dueAmount = Math.max(0, totalFee - totalPaid - totalDiscount - paidDiscount);

        const unpaid = assignments.filter((a) => a.status !== 'paid');
        const nextDueDate = unpaid.length > 0
            ? new Date(Math.min(...unpaid.map((a) => new Date(a.dueDate).getTime()))).toISOString()
            : null;

        return {
            student,
            summary: { totalFee, totalDiscount, paidAmount: totalPaid, dueAmount, nextDueDate },
            assignments,
        };
    }

    /* =====================
       Razorpay Payments
    ===================== */

    async createRazorpayOrder(assignmentId: string, studentId: string, requestedAmount?: number) {
        const assignment = await this.assignmentModel.findOne({
            _id: assignmentId,
            studentId,
        });
        if (!assignment) throw new NotFoundException('Fee assignment not found');
        if (assignment.status === 'paid') {
            throw new BadRequestException('This fee is already paid.');
        }

        const fine = await this.calculateFineForAssignment(assignment.toObject());
        const activeDiscount = this.getActiveDiscountAmount(assignment.toObject());
        const remainingBase = assignment.amount - (assignment.paidAmount || 0);
        const netPayable = Math.max(0, remainingBase + fine - activeDiscount);

        let orderAmount = netPayable;
        if (requestedAmount !== undefined && requestedAmount !== null) {
            if (requestedAmount < 1) throw new BadRequestException('Payment amount must be at least 1.');
            if (requestedAmount > netPayable) {
                throw new BadRequestException(`Amount exceeds remaining balance of ${netPayable}.`);
            }
            orderAmount = requestedAmount;
        }

        if (orderAmount <= 0) {
            throw new BadRequestException('Nothing to pay.');
        }

        if (assignment.razorpayOrderId && Math.abs(((assignment as any).razorpayOrderAmount || 0) - orderAmount) < 0.01) {
            return {
                orderId: assignment.razorpayOrderId,
                amount: Math.round(orderAmount * 100),
                currency: 'INR',
                keyId: this.razorpayKeyId,
                assignmentId: String(assignment._id),
            };
        }

        const order = await this.getRazorpay().orders.create({
            amount: Math.round(orderAmount * 100),
            currency: 'INR',
            receipt: `fee_${assignmentId}_${Date.now()}`,
            notes: { assignmentId, studentId },
        });

        assignment.razorpayOrderId = order.id;
        (assignment as any).razorpayOrderAmount = orderAmount;
        assignment.fineAmount = fine;
        assignment.discountAmount = activeDiscount;
        await assignment.save();

        return {
            orderId: order.id,
            amount: order.amount,
            currency: order.currency,
            keyId: this.razorpayKeyId,
            assignmentId: String(assignment._id),
        };
    }

    async verifyRazorpayPayment(
        assignmentId: string,
        studentId: string,
        body: { razorpay_order_id: string; razorpay_payment_id: string; razorpay_signature: string },
    ) {
        if (!this.razorpayKeySecret) {
            throw new BadRequestException('Payment verification is not configured.');
        }

        const sign = crypto
            .createHmac('sha256', this.razorpayKeySecret)
            .update(`${body.razorpay_order_id}|${body.razorpay_payment_id}`)
            .digest('hex');

        if (!timingSafeEqual(sign, body.razorpay_signature)) {
            throw new BadRequestException('Payment signature verification failed');
        }

        // Primary lookup: match by orderId on the assignment
        let assignment = await this.assignmentModel.findOne({
            _id: assignmentId,
            studentId,
            razorpayOrderId: body.razorpay_order_id,
        }).lean();

        // Fallback: if an offline payment cleared razorpayOrderId, look up by _id + studentId only
        if (!assignment) {
            assignment = await this.assignmentModel.findOne({ _id: assignmentId, studentId }).lean();
            if (!assignment) throw new NotFoundException('Fee assignment not found');

            // Check if a FeePayment already exists for this Razorpay order (webhook or prior verify)
            const existingPayment = await this.feePaymentModel.findOne(
                { assignmentId, razorpayOrderId: body.razorpay_order_id },
                { receiptNumber: 1 },
            ).lean();
            if (existingPayment) {
                return { status: assignment.status, receiptNumber: existingPayment.receiptNumber };
            }

            // Assignment exists but orderId was cleared — fee was paid via another channel
            if (assignment.status === 'paid') {
                return { status: 'paid', receiptNumber: assignment.receiptNumber };
            }
            // If still unpaid/partial but orderId was cleared (admin offline payment changed state),
            // the Razorpay payment is orphaned — student needs a refund from Razorpay
            throw new BadRequestException(
                'This fee was updated by an admin while your payment was processing. Please contact the school office for a refund.',
            );
        }

        if (assignment.status === 'paid') {
            const existingPayment = await this.feePaymentModel.findOne(
                { assignmentId, razorpayOrderId: body.razorpay_order_id },
                { receiptNumber: 1 },
            ).lean();
            return { status: 'paid', receiptNumber: existingPayment?.receiptNumber || assignment.receiptNumber };
        }

        const expectedAmount: number | undefined = (assignment as any).razorpayOrderAmount;
        if (expectedAmount != null && expectedAmount > 0) {
            try {
                const payment = await this.getRazorpay().payments.fetch(body.razorpay_payment_id);
                const expectedPaise = Math.round(expectedAmount * 100);
                if (Number(payment.amount) !== expectedPaise) {
                    throw new BadRequestException(
                        `Payment amount mismatch: expected ${expectedPaise} paise, got ${payment.amount} paise`,
                    );
                }
            } catch (err) {
                if (err instanceof BadRequestException) throw err;
                throw new BadRequestException('Unable to verify payment amount with Razorpay. Please try again.');
            }
        } else if (expectedAmount === 0) {
            throw new BadRequestException('Invalid order amount.');
        }

        // Use stored order amount; fall back to remaining balance only if not stored
        const paymentAmount = (expectedAmount != null && expectedAmount > 0)
            ? expectedAmount
            : (assignment.amount - (assignment.paidAmount || 0));

        const payer = await this.studentModel.findById(studentId, { name: 1 }).lean();
        const payerLabel = payer?.name ? `Online - ${payer.name}` : 'Online Payment';

        try {
            const { receiptNumber, assignment: updated } = await this.recordPaymentCore(
                assignmentId,
                studentId,
                paymentAmount,
                {
                    paymentMethod: 'razorpay',
                    razorpayOrderId: body.razorpay_order_id,
                    razorpayPaymentId: body.razorpay_payment_id,
                    razorpaySignature: body.razorpay_signature,
                    collectedByName: payerLabel,
                },
            );
            return { status: updated.status, receiptNumber };
        } catch (err) {
            // Concurrent verify/webhook race — check if payment was already recorded
            if (err instanceof BadRequestException && (err.message || '').includes('concurrently')) {
                const existingPayment = await this.feePaymentModel.findOne(
                    { assignmentId, razorpayOrderId: body.razorpay_order_id },
                    { receiptNumber: 1 },
                ).lean();
                if (existingPayment) {
                    const latest = await this.assignmentModel.findById(assignmentId, { status: 1 }).lean();
                    return { status: latest?.status || 'paid', receiptNumber: existingPayment.receiptNumber };
                }
            }
            throw err;
        }
    }

    /* =====================
       Offline Payment (Admin)
    ===================== */

    private async recordPaymentCore(
        assignmentId: string,
        studentId: string,
        paymentAmount: number,
        details: {
            paymentMethod: string;
            razorpayOrderId?: string;
            razorpayPaymentId?: string;
            razorpaySignature?: string;
            transactionId?: string;
            collectedBy?: string;
            collectedByName?: string;
            receiptUrl?: string;
            notes?: string;
        },
    ) {
        const query: any = { _id: assignmentId };
        if (studentId) query.studentId = studentId;

        const assignment = await this.assignmentModel.findOne(query).lean();
        if (!assignment) throw new NotFoundException('Fee assignment not found');
        if (assignment.status === 'paid') {
            throw new BadRequestException('This fee is already paid.');
        }

        const fine = await this.calculateFineForAssignment(assignment);
        const activeDiscount = this.getActiveDiscountAmount(assignment);
        const remainingBase = assignment.amount - (assignment.paidAmount || 0);
        const netPayable = Math.max(0, remainingBase + fine - activeDiscount);

        if (paymentAmount < 1) throw new BadRequestException('Payment amount must be at least 1.');
        if (paymentAmount > netPayable + 1) {
            throw new BadRequestException(`Amount exceeds remaining balance of ${netPayable}.`);
        }
        // Clamp to netPayable to handle rounding
        const clampedAmount = Math.min(paymentAmount, netPayable);

        const fineComponent = Math.max(0, clampedAmount - remainingBase);

        const newPaidAmount = (assignment.paidAmount || 0) + clampedAmount;
        const totalNeeded = assignment.amount + fine - activeDiscount;
        const newStatus = newPaidAmount >= totalNeeded ? 'paid' : 'partial';

        // Mirror latest payment details onto the assignment so list views
        // (/fees/assignments) can show Method/Txn/Collected By/Receipt without
        // joining FeePayment history. Receipt number is written in a follow-up
        // update after FeePayment creation succeeds. When a subsequent payment
        // omits optional fields (e.g. cash → no txn after a UPI partial with
        // txn), we $unset to prevent stale values lingering from a prior payment.
        const setFields: any = {
            status: newStatus,
            fineAmount: fine,
            discountAmount: activeDiscount,
            paymentMethod: details.paymentMethod,
        };
        const unsetFields: any = { razorpayOrderId: 1, razorpayOrderAmount: 1 };

        if (details.collectedBy) setFields.collectedBy = details.collectedBy;
        else unsetFields.collectedBy = 1;

        if (details.transactionId) setFields.transactionId = details.transactionId;
        else unsetFields.transactionId = 1;

        if (newStatus === 'paid') setFields.paidAt = new Date();

        // Atomic claim — paidAmount guard prevents concurrent overpayment (TOCTOU)
        const claimed = await this.assignmentModel.findOneAndUpdate(
            {
                _id: assignmentId,
                status: { $ne: 'paid' },
                paidAmount: assignment.paidAmount || 0,
            },
            {
                $inc: { paidAmount: clampedAmount },
                $set: setFields,
                $unset: unsetFields,
            },
            { new: true },
        ).lean();

        if (!claimed) {
            throw new BadRequestException('Payment was modified concurrently. Please retry.');
        }

        // Create FeePayment with receipt — retry on duplicate receipt (E11000)
        let feePayment: any;
        for (let receiptAttempt = 0; receiptAttempt < 3; receiptAttempt++) {
            const receiptNumber = await this.generateReceiptNumber();
            try {
                feePayment = await this.feePaymentModel.create({
                    assignmentId: String(assignment._id),
                    studentId: assignment.studentId,
                    academicYearId: assignment.academicYearId,
                    amount: clampedAmount,
                    fineComponent,
                    paymentMethod: details.paymentMethod,
                    receiptNumber,
                    razorpayOrderId: details.razorpayOrderId,
                    razorpayPaymentId: details.razorpayPaymentId,
                    razorpaySignature: details.razorpaySignature,
                    transactionId: details.transactionId,
                    collectedBy: details.collectedBy,
                    collectedByName: details.collectedByName,
                    receiptUrl: details.receiptUrl,
                    notes: details.notes,
                    paidAt: new Date(),
                });
                break;
            } catch (err: any) {
                // E11000 = duplicate key (receipt number collision)
                if (err?.code === 11000 && receiptAttempt < 2) continue;
                throw err;
            }
        }
        if (!feePayment) throw new BadRequestException('Failed to generate unique receipt number. Please retry.');

        // Mirror the (now-confirmed-unique) receipt number onto the assignment for
        // list views. Non-critical — if this update fails the FeePayment still wins
        // as the source of truth; frontend can fall back to joining history.
        try {
            await this.assignmentModel.updateOne(
                { _id: claimed._id },
                { $set: { receiptNumber: feePayment.receiptNumber } },
            );
        } catch (err: any) {
            this.logger.warn(`Assignment receiptNumber mirror failed for ${String(claimed._id)}: ${err?.message}`);
        }

        // Upsert matching Invoice (Option B: one per FeeAssignment, updated each payment).
        // Wrapped so invoice-side failures never roll back the payment. Failures are
        // also surfaced to the caller via `invoiceWarning` so the admin UI can toast.
        let invoiceWarning: string | null = null;
        try {
            const billAmount = (claimed.amount || 0) + (claimed.fineAmount || 0) - (claimed.discountAmount || 0);
            await this.invoicesService.upsertFromFeePayment({
                feeAssignmentId: String(claimed._id),
                studentId: claimed.studentId,
                classId: claimed.classId,
                academicYearId: claimed.academicYearId,
                billAmount,
                totalPaid: claimed.paidAmount || 0,
                paymentMethod: details.paymentMethod,
                generatedBy: details.collectedByName || details.collectedBy || 'System',
                feeName: claimed.feeName,
                installment: claimed.installment,
                fineAmount: claimed.fineAmount || 0,
            });
        } catch (err: any) {
            this.logger.warn(`Invoice upsert failed for assignment=${String(claimed._id)}: ${err?.message}`);
            invoiceWarning = 'Payment recorded, but the invoice listing could not be updated. It will self-heal on the next payment against this fee, or contact support.';
        }

        return { assignment: claimed, payment: feePayment.toObject(), receiptNumber: feePayment.receiptNumber, invoiceWarning };
    }

    async recordOfflinePayment(
        assignmentId: string,
        dto: RecordPaymentDto,
        adminId: string,
        receipt?: Express.Multer.File,
    ) {
        // Route is @Roles(Role.ADMIN)-gated, so the caller is always an admin.
        // `collectedBy` (adminId) is still stored on the assignment for audit.
        const collectedByName = 'Admin';

        let receiptRelativePath: string | undefined;
        if (receipt) {
            const uploadsDir = path.join(process.cwd(), 'uploads', 'fee-receipts');
            await fs.mkdir(uploadsDir, { recursive: true });
            const filename = `receipt-${randomBytes(16).toString('hex')}${path.extname(receipt.originalname) || ''}`;
            const filepath = path.join(uploadsDir, filename);
            await fs.writeFile(filepath, receipt.buffer);
            receiptRelativePath = `/uploads/fee-receipts/${filename}`;
        }

        const { assignment, payment, receiptNumber, invoiceWarning } = await this.recordPaymentCore(
            assignmentId,
            '',
            dto.amount,
            {
                paymentMethod: dto.paymentMethod,
                transactionId: dto.transactionId,
                collectedBy: adminId,
                collectedByName,
                receiptUrl: receiptRelativePath,
                notes: dto.notes,
            },
        );

        const paymentWithFullUrl = payment?.receiptUrl
            ? { ...payment, receiptUrl: toFullUrl(payment.receiptUrl) }
            : payment;

        return { ...assignment, receiptNumber, payment: paymentWithFullUrl, invoiceWarning };
    }

    /* =====================
       Webhook (Razorpay)
    ===================== */

    async handleWebhook(signature: string | undefined, rawBody: Buffer, jsonBody: any) {
        if (!this.razorpayWebhookSecret) throw new BadRequestException('Webhook not configured');

        const expected = crypto.createHmac('sha256', this.razorpayWebhookSecret).update(rawBody).digest('hex');
        if (!signature || !timingSafeEqual(expected, signature)) {
            this.logger.warn('Webhook received with invalid signature');
            // Return 200 to prevent Razorpay from retrying invalid signatures
            return { received: true };
        }

        const event = jsonBody?.event;
        const entity = jsonBody?.payload?.payment?.entity || jsonBody?.payload?.order?.entity;

        if (event === 'payment.captured' && entity) {
            const notes = entity.notes || {};
            if (notes.type === 'transport') return { received: true };

            const orderId = entity.order_id;
            const assignment = await this.assignmentModel.findOne({ razorpayOrderId: orderId }).lean();
            if (!assignment) {
                this.logger.warn(`Webhook: no assignment found for orderId=${orderId}`);
                return { received: true };
            }
            if (assignment.status !== 'paid') {
                const expectedAmount: number | undefined = (assignment as any).razorpayOrderAmount;
                if (expectedAmount != null && expectedAmount > 0) {
                    const expectedPaise = Math.round(expectedAmount * 100);
                    if (Number(entity.amount) !== expectedPaise) {
                        this.logger.warn(`Webhook amount mismatch: orderId=${orderId} expected=${expectedPaise} got=${entity.amount}`);
                        return { received: true };
                    }
                } else if (expectedAmount === 0) {
                    this.logger.warn(`Webhook: zero-amount order for orderId=${orderId}, skipping`);
                    return { received: true };
                }

                const webhookPaymentAmount = (expectedAmount != null && expectedAmount > 0)
                    ? expectedAmount
                    : (assignment.amount - (assignment.paidAmount || 0));

                const payer = await this.studentModel.findById(assignment.studentId, { name: 1 }).lean();
                const payerLabel = payer?.name ? `Online - ${payer.name}` : 'Online Payment';
                try {
                    await this.recordPaymentCore(
                        String(assignment._id),
                        assignment.studentId,
                        webhookPaymentAmount,
                        {
                            paymentMethod: 'razorpay',
                            razorpayOrderId: orderId,
                            razorpayPaymentId: entity.id,
                            collectedByName: payerLabel,
                        },
                    );
                } catch (err) {
                    this.logger.warn(`Webhook: payment recording failed for orderId=${orderId}: ${(err as any)?.message}`);
                }
            }
        }

        return { received: true };
    }

    /**
     * Sync assignments: generates missing (student, installment) pairs for
     * every active fee structure. Covers two gaps:
     *   1. Student added AFTER structure creation → entire student missing
     *   2. Structure end date extended → existing student missing new installments
     * Dedupes on (studentId, installment); the unique index on
     * (studentId, feeStructureId, installment) is the safety net.
     */
    async syncAssignments(structureId?: string, academicYearId?: string) {
        const query: any = { isActive: true };
        if (structureId) query._id = structureId;
        if (academicYearId) query.academicYearId = academicYearId;
        const structures = await this.structureModel.find(query).lean();

        let docsCreated = 0;
        const affectedStudentIds = new Set<string>();

        for (const structure of structures) {
            const classIds = structure.classIds || [];
            const studentQuery: any = { academicYearId: structure.academicYearId, isActive: true };
            if (!classIds.includes('all')) studentQuery.classId = { $in: classIds };

            const students = await this.studentModel.find(studentQuery, { _id: 1, classId: 1 }).limit(10000).lean();
            if (students.length === 0) continue;

            const ay = await this.academicYearModel.findById(structure.academicYearId).lean();
            if (!ay) continue;
            const { effectiveStart, effectiveEnd } = this.resolveEffectiveRange(
                structure as FeeStructure & { _id: string; startDate?: Date; endDate?: Date },
                { startDate: new Date(ay.startDate), endDate: new Date(ay.endDate) },
            );

            const allDocs = this.buildAssignmentDocs(
                structure as FeeStructure & { _id: string },
                students,
                effectiveStart,
                effectiveEnd,
            );
            if (allDocs.length === 0) continue;

            // Pull existing (studentId, installment) pairs for this structure
            // and skip them — preserves paid/partial rows' payment state and
            // avoids relying on the catch-path for expected duplicates.
            const existingPairs = await this.assignmentModel
                .find(
                    { feeStructureId: String(structure._id) },
                    { studentId: 1, installment: 1 },
                )
                .lean();
            const existingKeys = new Set(
                existingPairs.map((r) => `${String(r.studentId)}_${r.installment}`),
            );
            const docs = allDocs.filter(
                (d) => !existingKeys.has(`${d.studentId}_${d.installment}`),
            );
            if (docs.length === 0) continue;

            // ordered: false — if a concurrent insert races us, let the unique
            // index reject the late doc rather than aborting the batch.
            const result = await this.assignmentModel.insertMany(docs, { ordered: false }).catch((err) => {
                if (err?.code === 11000 || err?.writeErrors) {
                    const errCount = err.writeErrors?.length ?? 0;
                    const insertedCount = err.insertedDocs?.length ?? Math.max(0, docs.length - errCount);
                    return { insertedDocs: err.insertedDocs ?? [], length: insertedCount };
                }
                throw err;
            });

            const insertedDocs: any[] = Array.isArray(result)
                ? result
                : ((result as any).insertedDocs ?? []);
            const insertedCount = Array.isArray(result)
                ? result.length
                : ((result as any).length ?? insertedDocs.length);

            docsCreated += insertedCount;
            for (const d of insertedDocs) {
                if (d?.studentId) affectedStudentIds.add(String(d.studentId));
            }
        }

        return { synced: docsCreated, studentCount: affectedStudentIds.size };
    }

    /**
     * Admin: update/regenerate unpaid assignments for a structure so they
     * reflect the current structure settings (dueDate from startDate/endDate,
     * amount, feeName, classId).
     *
     * Strategy: bulkWrite with upsert per (studentId, feeStructureId, installment).
     * This preserves each row's original `status`, `paidAmount`,
     * `fineAmount`, and `discountAmount` — only the "shape" fields are updated.
     *
     * Paid/partial rows are never touched — they are filtered out of the
     * expected doc set AND never deleted as orphans.
     *
     * After upsert: remove orphan unpaid rows (rows for students/installments
     * that no longer appear in the current structure's class/window).
     */
    async regenerateUnpaidAssignments(structureId: string) {
        const structure = await this.structureModel.findById(structureId).lean();
        if (!structure) throw new NotFoundException('Fee structure not found');

        const classIds = structure.classIds || [];
        const q: any = { academicYearId: structure.academicYearId, isActive: true };
        if (!classIds.includes('all')) q.classId = { $in: classIds };
        const students = await this.studentModel.find(q, { _id: 1, classId: 1 }).limit(10000).lean();
        if (students.length === 0) {
            return { generated: 0, updated: 0, studentCount: 0, deletedOld: 0 };
        }

        const ay = await this.academicYearModel.findById(structure.academicYearId).lean();
        if (!ay) throw new NotFoundException('Academic year not found for this fee structure');
        const { effectiveStart, effectiveEnd } = this.resolveEffectiveRange(
            structure as FeeStructure & { _id: string; startDate?: Date; endDate?: Date },
            { startDate: new Date(ay.startDate), endDate: new Date(ay.endDate) },
        );

        const allDocs = this.buildAssignmentDocs(
            structure as FeeStructure & { _id: string },
            students,
            effectiveStart,
            effectiveEnd,
        );

        // Pre-fetch (studentId, installment) pairs that are already paid/partial
        // for this structure. These rows are LOCKED — their dueDate/amount are
        // frozen at payment time and must never be overwritten by a regen.
        // We skip generating docs for these keys entirely.
        const lockedPairs = await this.assignmentModel
            .find(
                { feeStructureId: structureId, status: { $in: ['paid', 'partial'] } },
                { studentId: 1, installment: 1 },
            )
            .lean();
        const lockedKeys = new Set(
            lockedPairs.map((r) => `${String(r.studentId)}_${r.installment}`),
        );
        const docs = allDocs.filter(
            (d) => !lockedKeys.has(`${d.studentId}_${d.installment}`),
        );

        // Upsert each expected UNLOCKED row. Only $set the shape fields;
        // $setOnInsert the transient fields so existing rows keep their
        // payment state (createdAt, status, paidAmount, discountFields).
        const ops = docs.map((doc) => ({
            updateOne: {
                filter: {
                    studentId: doc.studentId,
                    feeStructureId: doc.feeStructureId,
                    installment: doc.installment,
                },
                update: {
                    $set: {
                        dueDate: doc.dueDate,
                        amount: doc.amount,
                        baseAmount: doc.baseAmount,
                        subFees: doc.subFees,
                        feeName: doc.feeName,
                        academicYearId: doc.academicYearId,
                        classId: doc.classId,
                    },
                    $setOnInsert: {
                        fineAmount: 0,
                        status: 'pending' as const,
                    },
                },
                upsert: true,
            },
        }));

        let upsertedCount = 0;
        let modifiedCount = 0;
        if (ops.length > 0) {
            for (let i = 0; i < ops.length; i += 5000) {
                const batch = ops.slice(i, i + 5000);
                const result = await this.assignmentModel.bulkWrite(batch, { ordered: false });
                upsertedCount += result.upsertedCount ?? 0;
                modifiedCount += result.modifiedCount ?? 0;
            }
        }

        // Orphan cleanup: any UNPAID row for this structure whose
        // (studentId, installment) pair isn't in the new doc set is stale
        // (student left the class, structure's classIds shrunk, etc.).
        // Paid/partial rows are always preserved — never deleted here.
        const validKeys = new Set(docs.map((d) => `${d.studentId}_${d.installment}`));
        const existingUnpaid = await this.assignmentModel
            .find(
                { feeStructureId: structureId, status: { $nin: ['paid', 'partial'] } },
                { _id: 1, studentId: 1, installment: 1 },
            )
            .lean();
        const orphanIds = existingUnpaid
            .filter((e) => !validKeys.has(`${String(e.studentId)}_${e.installment}`))
            .map((e) => e._id);
        let deletedOld = 0;
        if (orphanIds.length > 0) {
            const del = await this.assignmentModel.deleteMany({ _id: { $in: orphanIds } });
            deletedOld = del.deletedCount ?? 0;
        }

        return {
            generated: upsertedCount,
            updated: modifiedCount,
            studentCount: students.length,
            deletedOld,
        };
    }

    /* =====================
       Helpers
    ===================== */

    /**
     * Auto-sync: creates missing fee assignments for a single student.
     * Called on dashboard load so students always see their complete fees
     * even if they were enrolled after the fee structure was created.
     */
    private async syncStudentAssignments(studentId: string, classId: string, academicYearId: string) {
        // Find all active structures that apply to this student's class
        const structures = await this.structureModel.find({
            academicYearId,
            isActive: true,
            $or: [{ classIds: classId }, { classIds: 'all' }],
        }).lean();
        if (structures.length === 0) return;

        // Which structures does this student already have assignments for?
        const existingStructureIds = new Set(
            await this.assignmentModel.distinct('feeStructureId', { studentId, academicYearId }),
        );

        // Only generate for structures this student is missing entirely
        const missingStructures = structures.filter((s) => !existingStructureIds.has(String(s._id)));
        if (missingStructures.length === 0) return;

        const ay = await this.academicYearModel.findById(academicYearId).lean();
        if (!ay) return;

        for (const structure of missingStructures) {
            const { effectiveStart, effectiveEnd } = this.resolveEffectiveRange(
                structure as FeeStructure & { _id: string; startDate?: Date; endDate?: Date },
                { startDate: new Date(ay.startDate), endDate: new Date(ay.endDate) },
            );
            const docs = this.buildAssignmentDocs(
                structure as FeeStructure & { _id: string },
                [{ _id: studentId, classId }],
                effectiveStart,
                effectiveEnd,
            );
            if (docs.length > 0) {
                await this.assignmentModel.insertMany(docs, { ordered: false }).catch((err) => {
                    if (err?.code === 11000 || err?.writeErrors) return; // skip duplicates
                    throw err;
                });
            }
        }
    }

    private async generateAssignments(
        structure: FeeStructure & { _id: string },
    ): Promise<{ docsCreated: number; studentCount: number }> {
        const classIds = structure.classIds || [];
        const q: any = { academicYearId: structure.academicYearId, isActive: true };
        if (!classIds.includes('all')) q.classId = { $in: classIds };

        const students = await this.studentModel.find(q, { _id: 1, classId: 1 }).limit(10000).lean();
        if (students.length === 0) return { docsCreated: 0, studentCount: 0 };

        const ay = await this.academicYearModel.findById(structure.academicYearId).lean();
        if (!ay) throw new NotFoundException('Academic year not found for this fee structure');
        const { effectiveStart, effectiveEnd } = this.resolveEffectiveRange(
            structure as FeeStructure & { _id: string; startDate?: Date; endDate?: Date },
            { startDate: new Date(ay.startDate), endDate: new Date(ay.endDate) },
        );

        const docs = this.buildAssignmentDocs(structure, students, effectiveStart, effectiveEnd);
        if (docs.length > 0) {
            // Insert in batches of 5000 to avoid hitting MongoDB's BSON size limit
            for (let i = 0; i < docs.length; i += 5000) {
                await this.assignmentModel.insertMany(docs.slice(i, i + 5000), { ordered: false }).catch((err) => {
                    if (err?.code === 11000 || err?.writeErrors) return; // skip duplicates
                    throw err;
                });
            }
        }
        return { docsCreated: docs.length, studentCount: students.length };
    }

    /** Builds assignment documents for a list of students. Uses explicit
     *  (effectiveStart, effectiveEnd) dates resolved by the caller —
     *  these come from structure.startDate/endDate when set, otherwise
     *  from the AcademicYear window. */
    private buildAssignmentDocs(
        structure: FeeStructure & { _id: string },
        students: { _id: any; classId: any }[],
        effectiveStart: Date,
        effectiveEnd: Date,
    ) {
        const subFees = structure.enableSubFees ? (structure.subFees || []) : [];
        const subFeesTotal = subFees.reduce((s, sf) => s + (sf.amount || 0), 0);
        const totalAmount = (structure.amount || 0) + subFeesTotal;
        const base = (stu: any, installment: string, dueDate: Date) => ({
            studentId: String(stu._id),
            feeStructureId: String(structure._id),
            academicYearId: structure.academicYearId,
            classId: String(stu.classId),
            feeName: structure.name,
            installment,
            amount: totalAmount,
            baseAmount: structure.amount,
            subFees: subFees.map((sf) => ({ name: sf.name, amount: sf.amount })),
            fineAmount: 0,
            dueDate,
            status: 'pending' as const,
        });

        switch (structure.feeType) {
            case 'one-time':
            case 'yearly': {
                // Two code paths to cover:
                //  1. NEW structures: admin picked a specific `startDate` — that
                //     date IS the due date. Use its full year/month/day. dueDay
                //     is ignored (it's a monthly/quarterly concept).
                //  2. LEGACY structures: no startDate set. Preserve the old
                //     contract of `dueMonth` + `dueDay` within the AY window
                //     (so e.g. "Annual fee due June 15" keeps working after
                //     the AY-start-month fix). yearForMonthInAY places the
                //     dueMonth in the correct calendar year of the AY.
                let y: number;
                let m: number;
                let day: number;
                if (structure.startDate) {
                    y = effectiveStart.getUTCFullYear();
                    m = effectiveStart.getUTCMonth() + 1;
                    day = clampDayToMonth(y, m, effectiveStart.getUTCDate());
                } else {
                    m = structure.dueMonth || 1;
                    y = yearForMonthInAY(effectiveStart, m);
                    day = clampDayToMonth(y, m, structure.dueDay);
                }
                const dueDate = new Date(Date.UTC(y, m - 1, day));
                const label = structure.feeType === 'one-time' ? 'One Time' : 'Yearly';
                return students.map((stu) => base(stu, label, dueDate));
            }
            case 'quarterly': {
                // Filter out quarterly installments that fall after the admin's
                // explicit endDate. generateQuarterlyDueDates always emits 4 at
                // offsets +0/+3/+6/+9 months regardless of window — that's useful
                // for a full-year AY but wrong when admin set a narrower range.
                const endMs = effectiveEnd.getTime();
                const allDates = generateQuarterlyDueDates(effectiveStart, effectiveEnd, structure.dueDay);
                const dates = allDates.filter((d) => d.getTime() <= endMs);
                const labels = buildQuarterLabels(effectiveStart.getUTCMonth() + 1);
                return students.flatMap((stu) =>
                    dates.map((dueDate, qi) => base(stu, labels[qi], dueDate)),
                );
            }
            case 'monthly':
            default: {
                const dates = generateMonthlyDueDates(effectiveStart, effectiveEnd, structure.dueDay);
                return students.flatMap((stu) =>
                    dates.map((dueDate) => {
                        const m = dueDate.getUTCMonth() + 1;
                        return base(stu, MONTHS[m], dueDate);
                    }),
                );
            }
        }
    }

    /** Resolve effective start/end for installment generation. Prefers
     *  the structure's explicit startDate/endDate; falls back to AY.
     *  If the resolved window is backward (end <= start), fails fast with a
     *  clear BadRequestException — prevents the month-walker from emitting
     *  up to 60 bogus installments trying to reach an unreachable end. */
    private resolveEffectiveRange(
        structure: FeeStructure & { _id: string; startDate?: Date; endDate?: Date },
        ay: { startDate: Date; endDate: Date },
    ): { effectiveStart: Date; effectiveEnd: Date } {
        const effectiveStart = structure.startDate
            ? new Date(structure.startDate)
            : new Date(ay.startDate);
        const effectiveEnd = structure.endDate
            ? new Date(structure.endDate)
            : new Date(ay.endDate);
        if (isNaN(effectiveStart.getTime()) || isNaN(effectiveEnd.getTime())) {
            throw new BadRequestException(
                `Fee structure "${structure.name}" has invalid start/end dates.`,
            );
        }
        if (effectiveEnd.getTime() <= effectiveStart.getTime()) {
            throw new BadRequestException(
                `Fee structure "${structure.name}" has an invalid installment window ` +
                `(start ${effectiveStart.toISOString().slice(0, 10)} is not before ` +
                `end ${effectiveEnd.toISOString().slice(0, 10)}). ` +
                `Either set endDate explicitly or adjust the academic year.`,
            );
        }
        return { effectiveStart, effectiveEnd };
    }

    /** Compute fine amount from a lateFine rule, daysLate, and base amount */
    private computeFine(lateFine: any, daysLate: number, baseAmount: number): number {
        if (!lateFine?.enabled || daysLate <= 0) return 0;

        const fineType = lateFine.type || 'per_day';
        switch (fineType) {
            case 'percentage':
                return Math.round(baseAmount * (lateFine.percentage || 0) / 100);
            case 'day_wise_slab': {
                const slabs = lateFine.slabs || [];
                // Find matching slab for daysLate
                for (const slab of slabs) {
                    if (daysLate >= slab.startDay && daysLate <= slab.endDay) {
                        return Math.round(slab.amount);
                    }
                }
                // If daysLate exceeds all slabs, use the last slab's amount
                if (slabs.length > 0) return Math.round(slabs[slabs.length - 1].amount);
                return 0;
            }
            case 'per_day':
            default:
                return Math.round(daysLate * (lateFine.amountPerDay || 0));
        }
    }

    private async calculateFineForAssignment(assignment: any): Promise<number> {
        if (assignment.status === 'paid') return assignment.fineAmount || 0;

        const dueDate = new Date(assignment.dueDate);
        const now = new Date();
        if (now <= dueDate) return 0;

        const structure = await this.structureModel
            .findById(assignment.feeStructureId, { lateFine: 1, amount: 1 })
            .lean();
        if (!structure?.lateFine?.enabled) return 0;

        const diffMs = now.getTime() - dueDate.getTime();
        const daysLate = Math.floor(diffMs / (1000 * 60 * 60 * 24));

        // Fine on remaining unpaid base, not full amount
        const remainingBase = assignment.amount - (assignment.paidAmount || 0);
        if (remainingBase <= 0) return 0;
        return this.computeFine(structure.lateFine, daysLate, remainingBase);
    }

    async recalculateFines(assignments: any[]): Promise<any[]> {
        if (assignments.length === 0) return assignments;

        const unpaid = assignments.filter((a) => a.status !== 'paid');
        if (unpaid.length === 0) return assignments;

        const structureIds = [...new Set(unpaid.map((a) => a.feeStructureId))];
        const structures = await this.structureModel
            .find({ _id: { $in: structureIds } }, { lateFine: 1 })
            .lean();
        const structureMap = new Map(structures.map((s) => [String(s._id), s]));

        const now = new Date();
        return assignments.map((a) => {
            if (a.status === 'paid') return a;
            const dueDate = new Date(a.dueDate);
            if (now <= dueDate) {
                const currentStatus = (a.paidAmount || 0) > 0 ? 'partial' as const : 'pending' as const;
                return { ...a, fineAmount: 0, status: currentStatus };
            }

            const structure = structureMap.get(String(a.feeStructureId));
            const diffMs = now.getTime() - dueDate.getTime();
            const daysLate = Math.floor(diffMs / (1000 * 60 * 60 * 24));

            const remainingBase = a.amount - (a.paidAmount || 0);
            const fine = (structure?.lateFine?.enabled && remainingBase > 0)
                ? this.computeFine(structure.lateFine, daysLate, remainingBase)
                : 0;
            return { ...a, fineAmount: fine, status: 'overdue' as const };
        });
    }

    async verifyReceipt(receiptNumber: string) {
        // Search FeePayment first (new partial payment records)
        const feePayment = await this.feePaymentModel.findOne(
            { receiptNumber },
            { assignmentId: 1, studentId: 1, amount: 1, fineComponent: 1, paymentMethod: 1, paidAt: 1, receiptNumber: 1 },
        ).lean();

        if (feePayment) {
            const student = await this.studentModel.findById(
                feePayment.studentId,
                { name: 1, uniqueId: 1, rollNumber: 1, classId: 1 },
            ).lean();

            const assignment = await this.assignmentModel.findById(
                feePayment.assignmentId,
                { feeName: 1, installment: 1, amount: 1 },
            ).lean();

            return {
                receiptNumber: feePayment.receiptNumber,
                studentName: student?.name || 'Unknown',
                studentId: student?.uniqueId || '',
                rollNumber: student?.rollNumber || '',
                feeName: assignment?.feeName || '',
                installment: assignment?.installment || '',
                amount: feePayment.amount,
                fineComponent: feePayment.fineComponent || 0,
                totalPaid: feePayment.amount,
                status: 'paid',
                paidAt: feePayment.paidAt,
                paymentMethod: feePayment.paymentMethod,
            };
        }

        // Fallback: search FeeAssignment (old records)
        const assignment = await this.assignmentModel.findOne(
            { receiptNumber },
            { studentId: 1, feeName: 1, installment: 1, amount: 1, fineAmount: 1, discountAmount: 1, status: 1, paidAt: 1, paymentMethod: 1, receiptNumber: 1, academicYearId: 1 },
        ).lean();
        if (!assignment) return null;

        const student = await this.studentModel.findById(
            assignment.studentId,
            { name: 1, uniqueId: 1, rollNumber: 1, classId: 1 },
        ).lean();

        return {
            receiptNumber: assignment.receiptNumber,
            studentName: student?.name || 'Unknown',
            studentId: student?.uniqueId || '',
            rollNumber: student?.rollNumber || '',
            feeName: assignment.feeName,
            installment: assignment.installment,
            amount: assignment.amount,
            fineAmount: assignment.fineAmount || 0,
            discountAmount: assignment.discountAmount || 0,
            totalPaid: assignment.amount + (assignment.fineAmount || 0) - (assignment.discountAmount || 0),
            status: assignment.status,
            paidAt: assignment.paidAt,
            paymentMethod: assignment.paymentMethod,
        };
    }

    async getPaymentHistory(assignmentId: string, studentId?: string) {
        const query: any = { assignmentId };
        if (studentId) query.studentId = studentId;

        const payments = await this.feePaymentModel
            .find(query)
            .sort({ paidAt: 1 })
            .limit(500)
            .lean();

        // `collectedByName` is stored at write time ('Admin' for new records).
        // For legacy records without it, derive from presence of `collectedBy`.
        return payments.map((p) => ({
            ...p,
            collectedByName: p.collectedByName || (p.collectedBy ? 'Admin' : undefined),
            receiptUrl: p.receiptUrl ? toFullUrl(p.receiptUrl) : undefined,
        }));
    }

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

        for (let attempt = 0; attempt < 3; attempt++) {
            // Count receipts across both collections (assignments for old records, payments for new)
            const [assignmentCount, paymentCount] = await Promise.all([
                this.assignmentModel.countDocuments({ receiptNumber: { $regex: `^${prefix}\\d+$` } }),
                this.feePaymentModel.countDocuments({ receiptNumber: { $regex: `^${prefix}\\d+$` } }),
            ]);
            const count = assignmentCount + paymentCount;

            const seq = count + 1 + attempt;
            const receiptNumber = `${prefix}${String(seq).padStart(6, '0')}`;
            const [existsA, existsP] = await Promise.all([
                this.assignmentModel.exists({ receiptNumber }),
                this.feePaymentModel.exists({ receiptNumber }),
            ]);
            if (!existsA && !existsP) return receiptNumber;
        }

        // Fallback: crypto-based unique receipt (always unique, no collision possible)
        return `${prefix}${crypto.randomBytes(4).toString('hex').toUpperCase()}`;
    }
}
