"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.canvassService = void 0;
const client_1 = require("@prisma/client");
const library_1 = require("@prisma/client/runtime/library");
const database_1 = require("../../../config/database");
const error_handler_1 = require("../../../middleware/error-handler");
const logger_1 = require("../../../utils/logger");
const metrics_1 = require("../../../utils/metrics");
const spatial_1 = require("../../../utils/spatial");
const crm_activity_1 = require("../../../utils/crm-activity");
const canvass_route_service_1 = require("./canvass-route.service");
const metrics_2 = require("../../../utils/metrics");
const notification_queue_service_1 = require("../../../services/notification-queue.service");
const notification_helper_1 = require("../../../services/notification.helper");
const env_1 = require("../../../config/env");
const rocketchat_webhook_service_1 = require("../../../services/rocketchat-webhook.service");
const listmonk_event_sync_service_1 = require("../../../services/listmonk-event-sync.service");
const achievements_service_1 = require("../../social/achievements.service");
const ADDRESS_SELECT = {
    id: true,
    unitNumber: true,
    firstName: true,
    lastName: true,
    email: true,
    phone: true,
    supportLevel: true,
    sign: true,
    signSize: true,
    notes: true,
    location: {
        select: {
            id: true,
            latitude: true,
            longitude: true,
            address: true,
            buildingNotes: true,
        },
    },
};
async function annotateAddressesWithVisits(addresses, userId) {
    const addressIds = addresses.map((a) => a.id);
    if (addressIds.length === 0)
        return [];
    const latestVisits = await database_1.prisma.canvassVisit.findMany({
        where: { addressId: { in: addressIds } },
        orderBy: { visitedAt: 'desc' },
        distinct: ['addressId'],
        select: {
            addressId: true,
            outcome: true,
            visitedAt: true,
            userId: true,
            user: { select: { name: true } },
        },
    });
    const visitMap = new Map(latestVisits.map((v) => [v.addressId, v]));
    return addresses.map((addr) => {
        const visit = visitMap.get(addr.id);
        return {
            ...addr,
            location: {
                ...addr.location,
                latitude: Number(addr.location.latitude),
                longitude: Number(addr.location.longitude),
            },
            lastVisit: visit
                ? {
                    outcome: visit.outcome,
                    visitedAt: visit.visitedAt,
                    visitorName: visit.user?.name ?? null,
                    isMyVisit: visit.userId === userId,
                }
                : null,
        };
    });
}
const ADMIN_ADDRESS_FIELDS = ['firstName', 'lastName', 'unitNumber', 'email', 'phone'];
const VOLUNTEER_ADDRESS_FIELDS = ['supportLevel', 'sign', 'signSize', 'notes'];
exports.canvassService = {
    // ─── Volunteer Methods ─────────────────────────────────────────────
    async getMyAssignments(userId) {
        const signups = await database_1.prisma.shiftSignup.findMany({
            where: {
                userId,
                status: client_1.SignupStatus.CONFIRMED,
                shift: { cutId: { not: null } },
            },
            include: {
                shift: {
                    include: {
                        cut: { select: { id: true, name: true, completionPercentage: true, geojson: true } },
                    },
                },
            },
            orderBy: { shift: { date: 'asc' } },
        });
        return signups
            .filter((s) => s.shift.cut)
            .map((s) => ({
            shiftId: s.shift.id,
            shiftTitle: s.shift.title,
            shiftDate: s.shift.date,
            startTime: s.shift.startTime,
            endTime: s.shift.endTime,
            location: s.shift.location,
            cutId: s.shift.cut.id,
            cutName: s.shift.cut.name,
            completionPercentage: s.shift.cut.completionPercentage,
        }));
    },
    async getMyStats(userId) {
        const today = new Date();
        today.setHours(0, 0, 0, 0);
        const [totalVisits, todayVisits, byOutcome, sessions] = await Promise.all([
            database_1.prisma.canvassVisit.count({ where: { userId } }),
            database_1.prisma.canvassVisit.count({ where: { userId, visitedAt: { gte: today } } }),
            database_1.prisma.canvassVisit.groupBy({
                by: ['outcome'],
                where: { userId },
                _count: true,
            }),
            database_1.prisma.canvassSession.count({ where: { userId } }),
        ]);
        const outcomeMap = {};
        for (const row of byOutcome) {
            outcomeMap[row.outcome] = row._count;
        }
        return { totalVisits, todayVisits, byOutcome: outcomeMap, sessions };
    },
    async getMyVisits(userId, filters) {
        const { page, limit } = filters;
        const skip = (page - 1) * limit;
        const [visits, total] = await Promise.all([
            database_1.prisma.canvassVisit.findMany({
                where: { userId },
                skip,
                take: limit,
                orderBy: { visitedAt: 'desc' },
                include: {
                    address: {
                        select: {
                            id: true,
                            unitNumber: true,
                            location: { select: { address: true } },
                        },
                    },
                },
            }),
            database_1.prisma.canvassVisit.count({ where: { userId } }),
        ]);
        return {
            visits,
            pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
        };
    },
    async getActiveSession(userId) {
        return database_1.prisma.canvassSession.findFirst({
            where: { userId, status: client_1.CanvassSessionStatus.ACTIVE },
            include: {
                cut: { select: { id: true, name: true } },
                shift: { select: { id: true, title: true } },
            },
        });
    },
    async startSession(userId, data) {
        // Check for existing active session
        const existing = await database_1.prisma.canvassSession.findFirst({
            where: { userId, status: client_1.CanvassSessionStatus.ACTIVE },
        });
        if (existing) {
            throw new error_handler_1.AppError(409, 'You already have an active canvass session', 'SESSION_ACTIVE');
        }
        // Verify cut exists
        const cut = await database_1.prisma.cut.findUnique({ where: { id: data.cutId } });
        if (!cut) {
            throw new error_handler_1.AppError(404, 'Cut not found', 'CUT_NOT_FOUND');
        }
        const session = await database_1.prisma.canvassSession.create({
            data: {
                userId,
                cutId: data.cutId,
                shiftId: data.shiftId,
                startLatitude: data.startLatitude,
                startLongitude: data.startLongitude,
            },
            include: {
                cut: { select: { id: true, name: true } },
                shift: { select: { id: true, title: true } },
            },
        });
        return session;
    },
    async endSession(sessionId, userId) {
        const session = await database_1.prisma.canvassSession.findUnique({ where: { id: sessionId } });
        if (!session) {
            throw new error_handler_1.AppError(404, 'Session not found', 'SESSION_NOT_FOUND');
        }
        if (session.userId !== userId) {
            throw new error_handler_1.AppError(403, 'Not your session', 'FORBIDDEN');
        }
        if (session.status !== client_1.CanvassSessionStatus.ACTIVE) {
            throw new error_handler_1.AppError(400, 'Session is not active', 'SESSION_NOT_ACTIVE');
        }
        const updated = await database_1.prisma.canvassSession.update({
            where: { id: sessionId },
            data: { status: client_1.CanvassSessionStatus.COMPLETED, endedAt: new Date() },
        });
        // Recalculate cut completion percentage
        await this.recalculateCutCompletion(session.cutId);
        // Notify Rocket.Chat
        try {
            const [rcUser, rcCut, rcVisitCount] = await Promise.all([
                database_1.prisma.user.findUnique({ where: { id: userId }, select: { name: true, email: true } }),
                database_1.prisma.cut.findUnique({ where: { id: session.cutId }, select: { name: true } }),
                database_1.prisma.canvassVisit.count({ where: { sessionId } }),
            ]);
            rocketchat_webhook_service_1.rocketchatWebhookService.onCanvassSessionCompleted({
                userName: rcUser?.name || rcUser?.email || 'Unknown',
                visitCount: rcVisitCount,
                cutName: rcCut?.name || undefined,
            }).catch(() => { });
        }
        catch { /* non-critical */ }
        // Notification: volunteer session summary
        try {
            if (await (0, notification_helper_1.isNotificationEnabled)('notifyVolunteerSessionSummary')) {
                const [user, cut, visitCount, outcomeGroups, trackingSession] = await Promise.all([
                    database_1.prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
                    database_1.prisma.cut.findUnique({ where: { id: session.cutId }, select: { name: true } }),
                    database_1.prisma.canvassVisit.count({ where: { sessionId } }),
                    database_1.prisma.canvassVisit.groupBy({ by: ['outcome'], where: { sessionId }, _count: true }),
                    database_1.prisma.trackingSession.findFirst({
                        where: { userId, canvassSessionId: sessionId },
                        select: { totalDistanceM: true },
                    }),
                ]);
                if (user && visitCount > 0) {
                    const durationMs = updated.endedAt
                        ? updated.endedAt.getTime() - session.startedAt.getTime()
                        : 0;
                    const durationMinutes = Math.round(durationMs / 60000);
                    const distanceKm = trackingSession?.totalDistanceM
                        ? Number(trackingSession.totalDistanceM) / 1000
                        : 0;
                    const outcomeBreakdown = {};
                    for (const row of outcomeGroups) {
                        outcomeBreakdown[row.outcome] = row._count;
                    }
                    await notification_queue_service_1.notificationQueueService.enqueue({
                        type: 'volunteer-session-summary',
                        volunteerEmail: user.email,
                        volunteerName: user.name || user.email,
                        cutName: cut?.name || 'Unknown area',
                        sessionDate: session.startedAt.toLocaleDateString('en-CA', {
                            weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
                        }),
                        visitCount,
                        durationMinutes,
                        distanceKm,
                        outcomeBreakdown,
                    });
                }
            }
        }
        catch (err) {
            logger_1.logger.error('Failed to enqueue session summary notification:', err);
        }
        // Listmonk event sync — add canvasser to subscribers
        try {
            const [syncUser, syncCut, syncVisitCount, syncOutcomes] = await Promise.all([
                database_1.prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
                database_1.prisma.cut.findUnique({ where: { id: session.cutId }, select: { name: true } }),
                database_1.prisma.canvassVisit.count({ where: { sessionId } }),
                database_1.prisma.canvassVisit.groupBy({ by: ['outcome'], where: { sessionId }, _count: true }),
            ]);
            if (syncUser) {
                const outcomes = {};
                for (const row of syncOutcomes) {
                    outcomes[row.outcome] = row._count;
                }
                listmonk_event_sync_service_1.listmonkEventSyncService.onCanvassSessionCompleted({
                    email: syncUser.email,
                    name: syncUser.name || syncUser.email,
                    cutName: syncCut?.name || 'Unknown',
                    visitCount: syncVisitCount,
                    outcomes,
                }).catch(() => { });
            }
        }
        catch { /* non-critical */ }
        return updated;
    },
    async getCutLocationsForCanvass(cutId, userId, bounds, limit) {
        const startTime = Date.now();
        const cut = await database_1.prisma.cut.findUnique({ where: { id: cutId } });
        if (!cut) {
            throw new error_handler_1.AppError(404, 'Cut not found', 'CUT_NOT_FOUND');
        }
        const polygons = (0, spatial_1.parseGeoJsonPolygon)(cut.geojson);
        // Two-stage filtering: bounds first (fast DB query), then polygon (in-memory)
        const where = {};
        if (bounds) {
            // Convert to Decimal for proper PostgreSQL type matching
            where.latitude = {
                gte: new library_1.Decimal(bounds.minLat.toString()),
                lte: new library_1.Decimal(bounds.maxLat.toString())
            };
            where.longitude = {
                gte: new library_1.Decimal(bounds.minLng.toString()),
                lte: new library_1.Decimal(bounds.maxLng.toString())
            };
        }
        // CRITICAL: Apply limit to ADDRESSES not locations
        // Fetch more locations than needed to account for multi-unit buildings
        const addressLimit = Math.min(limit || 5000, 5000);
        const locationFetchLimit = Math.ceil(addressLimit * 1.5); // Fetch 50% more locations to ensure we get enough addresses
        const allLocations = await database_1.prisma.location.findMany({
            where,
            select: {
                id: true,
                latitude: true,
                longitude: true,
                address: true,
                buildingNotes: true,
                addresses: {
                    select: {
                        id: true,
                        unitNumber: true,
                        firstName: true,
                        lastName: true,
                        email: true,
                        phone: true,
                        supportLevel: true,
                        sign: true,
                        signSize: true,
                        notes: true,
                    },
                },
            },
            take: locationFetchLimit,
        });
        // Filter locations by polygon and flatten addresses, RESPECTING ADDRESS LIMIT
        const addressesInCut = [];
        let addressCount = 0;
        for (const loc of allLocations) {
            // Stop if we've reached the address limit
            if (addressCount >= addressLimit) {
                break;
            }
            const lat = Number(loc.latitude);
            const lng = Number(loc.longitude);
            if (polygons.some((poly) => (0, spatial_1.isPointInPolygon)(lat, lng, poly))) {
                for (const addr of loc.addresses) {
                    // Check limit before adding each address
                    if (addressCount >= addressLimit) {
                        break;
                    }
                    addressesInCut.push({
                        ...addr,
                        location: {
                            id: loc.id,
                            latitude: loc.latitude,
                            longitude: loc.longitude,
                            address: loc.address,
                            buildingNotes: loc.buildingNotes,
                        },
                    });
                    addressCount++;
                }
            }
        }
        const result = await annotateAddressesWithVisits(addressesInCut, userId);
        const durationSeconds = (Date.now() - startTime) / 1000;
        (0, metrics_1.recordLocationQuery)('canvass_cut', !!bounds, result.length, durationSeconds);
        return result;
    },
    async getAllLocationsForCanvass(userId, bounds, limit) {
        const startTime = Date.now();
        const where = {};
        if (bounds) {
            // Convert to Decimal for proper PostgreSQL type matching
            where.latitude = {
                gte: new library_1.Decimal(bounds.minLat.toString()),
                lte: new library_1.Decimal(bounds.maxLat.toString())
            };
            where.longitude = {
                gte: new library_1.Decimal(bounds.minLng.toString()),
                lte: new library_1.Decimal(bounds.maxLng.toString())
            };
        }
        // CRITICAL: Apply limit to ADDRESSES not locations
        // Fetch more locations than needed to account for multi-unit buildings
        const addressLimit = Math.min(limit || 5000, 5000);
        const locationFetchLimit = Math.ceil(addressLimit * 1.5); // Fetch 50% more locations to ensure we get enough addresses
        const allLocations = await database_1.prisma.location.findMany({
            where,
            select: {
                id: true,
                latitude: true,
                longitude: true,
                address: true,
                buildingNotes: true,
                addresses: {
                    select: {
                        id: true,
                        unitNumber: true,
                        firstName: true,
                        lastName: true,
                        email: true,
                        phone: true,
                        supportLevel: true,
                        sign: true,
                        signSize: true,
                        notes: true,
                    },
                },
            },
            take: locationFetchLimit,
        });
        // Flatten addresses with location embedded, RESPECTING ADDRESS LIMIT
        const allAddresses = [];
        let addressCount = 0;
        for (const loc of allLocations) {
            // Stop if we've reached the address limit
            if (addressCount >= addressLimit) {
                break;
            }
            for (const addr of loc.addresses) {
                // Check limit before adding each address
                if (addressCount >= addressLimit) {
                    break;
                }
                allAddresses.push({
                    ...addr,
                    location: {
                        id: loc.id,
                        latitude: loc.latitude,
                        longitude: loc.longitude,
                        address: loc.address,
                        buildingNotes: loc.buildingNotes,
                    },
                });
                addressCount++;
            }
        }
        const result = await annotateAddressesWithVisits(allAddresses, userId);
        const durationSeconds = (Date.now() - startTime) / 1000;
        (0, metrics_1.recordLocationQuery)('canvass_all', !!bounds, result.length, durationSeconds);
        return result;
    },
    async updateAddressAsVolunteer(addressId, userId, role, data) {
        const existing = await database_1.prisma.address.findUnique({ where: { id: addressId } });
        if (!existing) {
            throw new error_handler_1.AppError(404, 'Address not found', 'ADDRESS_NOT_FOUND');
        }
        const isAdmin = role === client_1.UserRole.SUPER_ADMIN || role === client_1.UserRole.MAP_ADMIN;
        // Build update data, stripping admin-only fields for non-admins
        const updateData = {
            updatedByUserId: userId,
        };
        for (const field of VOLUNTEER_ADDRESS_FIELDS) {
            if (data[field] !== undefined) {
                updateData[field] = data[field];
            }
        }
        if (isAdmin) {
            for (const field of ADMIN_ADDRESS_FIELDS) {
                if (data[field] !== undefined) {
                    updateData[field] = data[field];
                }
            }
        }
        return database_1.prisma.address.update({ where: { id: addressId }, data: updateData });
    },
    async getWalkingRoute(cutId, userId, filters) {
        const addresses = await this.getCutLocationsForCanvass(cutId, userId);
        let filtered = addresses;
        if (filters.excludeVisited) {
            filtered = addresses.filter((a) => !a.lastVisit);
        }
        const routeLocations = filtered.map((a) => ({
            id: a.location.id, // Use location ID for routing
            latitude: a.location.latitude,
            longitude: a.location.longitude,
        }));
        const cut = await database_1.prisma.cut.findUnique({
            where: { id: cutId },
            select: { geojson: true },
        });
        return (0, canvass_route_service_1.calculateWalkingRoute)(routeLocations, filters.startLatitude, filters.startLongitude, cut?.geojson);
    },
    async recordVisit(userId, data) {
        // Verify address exists
        const address = await database_1.prisma.address.findUnique({ where: { id: data.addressId } });
        if (!address) {
            throw new error_handler_1.AppError(404, 'Address not found', 'ADDRESS_NOT_FOUND');
        }
        // Create visit record
        const visit = await database_1.prisma.canvassVisit.create({
            data: {
                addressId: data.addressId,
                userId,
                shiftId: data.shiftId,
                sessionId: data.sessionId,
                outcome: data.outcome,
                supportLevel: data.supportLevel,
                signRequested: data.signRequested ?? false,
                signSize: data.signSize,
                notes: data.notes,
                durationSeconds: data.durationSeconds,
            },
            include: {
                address: {
                    select: {
                        id: true,
                        unitNumber: true,
                        location: { select: { address: true } },
                    },
                },
            },
        });
        // If SPOKE_WITH and updateLocation, push data to the address record
        if (data.outcome === client_1.VisitOutcome.SPOKE_WITH && data.updateLocation) {
            const updateData = {
                updatedByUserId: userId,
            };
            if (data.supportLevel)
                updateData.supportLevel = data.supportLevel;
            if (data.signRequested !== undefined)
                updateData.sign = data.signRequested;
            if (data.signSize)
                updateData.signSize = data.signSize;
            if (data.notes) {
                const timestamp = new Date().toISOString().split('T')[0];
                const prefix = `[${timestamp}] ${data.notes}`;
                updateData.notes = address.notes ? `${prefix}\n${address.notes}` : prefix;
            }
            const updatedAddress = await database_1.prisma.address.update({
                where: { id: data.addressId },
                data: updateData,
                include: { location: { select: { address: true } } },
            });
            // Sync support level change to Listmonk (fire-and-forget)
            if (updatedAddress.email) {
                const name = [updatedAddress.firstName, updatedAddress.lastName].filter(Boolean).join(' ');
                listmonk_event_sync_service_1.listmonkEventSyncService.onAddressUpdated({
                    email: updatedAddress.email,
                    name,
                    supportLevel: updatedAddress.supportLevel,
                    sign: updatedAddress.sign,
                    address: updatedAddress.location.address,
                }).catch(() => { });
            }
        }
        (0, metrics_2.recordCanvassVisit)(data.outcome);
        // CRM activity via ContactAddress lookup (fire-and-forget)
        database_1.prisma.contactAddress.findFirst({
            where: { addressId: data.addressId },
            select: { contactId: true },
        }).then((ca) => {
            if (ca) {
                (0, crm_activity_1.recordCrmActivity)({
                    contactId: ca.contactId,
                    activityType: 'CANVASS_VISIT',
                    title: `Canvass visit: ${data.outcome}`,
                    metadata: { addressId: data.addressId, outcome: data.outcome, visitId: visit.id },
                }).catch(() => { });
            }
        }).catch(() => { });
        // Achievement check (fire-and-forget)
        achievements_service_1.achievementsService.checkAndUnlock(userId, ['canvass']).catch(() => { });
        // Notification: sign request alert for admins
        if (data.signRequested) {
            try {
                if (await (0, notification_helper_1.isNotificationEnabled)('notifyAdminSignRequested')) {
                    const adminEmails = await (0, notification_helper_1.getAdminEmailsByRole)([client_1.UserRole.SUPER_ADMIN, client_1.UserRole.MAP_ADMIN]);
                    if (adminEmails.length > 0) {
                        const [volunteer, shift] = await Promise.all([
                            database_1.prisma.user.findUnique({ where: { id: userId }, select: { name: true } }),
                            data.shiftId ? database_1.prisma.shift.findUnique({ where: { id: data.shiftId }, select: { title: true } }) : null,
                        ]);
                        const addressStr = visit.address?.location?.address || 'Unknown address';
                        const adminUrl = `${env_1.env.ADMIN_URL || 'http://localhost:3000'}/app/canvass/dashboard`;
                        await notification_queue_service_1.notificationQueueService.enqueue({
                            type: 'admin-sign-requested',
                            adminEmails,
                            volunteerName: volunteer?.name || 'Unknown',
                            address: addressStr,
                            shiftTitle: shift?.title || 'No shift',
                            signSize: data.signSize || '',
                            adminUrl,
                        });
                    }
                }
            }
            catch (err) {
                logger_1.logger.error('Failed to enqueue sign request notification:', err);
            }
        }
        return visit;
    },
    async recordBulkVisit(userId, data) {
        // Verify session exists and belongs to user
        if (data.sessionId) {
            const session = await database_1.prisma.canvassSession.findUnique({
                where: { id: data.sessionId },
                include: { cut: true },
            });
            if (!session || session.userId !== userId) {
                throw new error_handler_1.AppError(403, 'Invalid session', 'INVALID_SESSION');
            }
            if (session.status !== 'ACTIVE') {
                throw new error_handler_1.AppError(400, 'Session is not active', 'INACTIVE_SESSION');
            }
            // Verify location is within cut boundary
            if (session.cutId && session.cut) {
                const location = await database_1.prisma.location.findUnique({
                    where: { id: data.locationId },
                });
                if (!location) {
                    throw new error_handler_1.AppError(404, 'Location not found', 'LOCATION_NOT_FOUND');
                }
                if (session.cut.geojson) {
                    try {
                        const polygons = (0, spatial_1.parseGeoJsonPolygon)(session.cut.geojson);
                        const isWithinBoundary = polygons.some((polygon) => (0, spatial_1.isPointInPolygon)(Number(location.latitude), Number(location.longitude), polygon));
                        if (!isWithinBoundary) {
                            throw new error_handler_1.AppError(403, 'Location is outside your assigned territory', 'LOCATION_OUT_OF_BOUNDS');
                        }
                    }
                    catch (err) {
                        logger_1.logger.error('Failed to validate location boundary for bulk visit', { error: err instanceof Error ? err.message : JSON.stringify(err) });
                        // Don't block the visit if polygon parsing fails, but log it
                    }
                }
            }
        }
        // Get all unvisited addresses at this location
        const addresses = await database_1.prisma.address.findMany({
            where: {
                locationId: data.locationId,
                canvassVisits: { none: {} },
            },
        });
        if (addresses.length === 0) {
            throw new error_handler_1.AppError(400, 'No unvisited addresses found at this location', 'NO_UNVISITED_ADDRESSES');
        }
        // Create visit record for each address
        const visits = await Promise.all(addresses.map((addr) => database_1.prisma.canvassVisit.create({
            data: {
                addressId: addr.id,
                userId,
                sessionId: data.sessionId,
                shiftId: data.shiftId,
                outcome: data.outcome,
                notes: data.notes ? `[BULK] ${data.notes}` : null,
                visitedAt: new Date(),
            },
        })));
        visits.forEach(() => (0, metrics_2.recordCanvassVisit)(data.outcome));
        return { created: visits.length, visits };
    },
    // ─── Admin Methods ─────────────────────────────────────────────────
    async getAdminStats() {
        const today = new Date();
        today.setHours(0, 0, 0, 0);
        const [totalVisits, todayVisits, activeSessions, activeVolunteers, cuts] = await Promise.all([
            database_1.prisma.canvassVisit.count(),
            database_1.prisma.canvassVisit.count({ where: { visitedAt: { gte: today } } }),
            database_1.prisma.canvassSession.count({ where: { status: client_1.CanvassSessionStatus.ACTIVE } }),
            database_1.prisma.canvassVisit.findMany({
                where: { visitedAt: { gte: today } },
                distinct: ['userId'],
                select: { userId: true },
            }),
            database_1.prisma.cut.findMany({
                select: { id: true, name: true, completionPercentage: true },
            }),
        ]);
        (0, metrics_2.setActiveCanvassSessions)(activeSessions);
        const avgCompletion = cuts.length > 0
            ? Math.round(cuts.reduce((sum, c) => sum + c.completionPercentage, 0) / cuts.length)
            : 0;
        return {
            totalVisits,
            todayVisits,
            activeSessions,
            activeVolunteers: activeVolunteers.length,
            overallCompletion: avgCompletion,
        };
    },
    async getCutStats(cutId) {
        const cut = await database_1.prisma.cut.findUnique({ where: { id: cutId } });
        if (!cut)
            throw new error_handler_1.AppError(404, 'Cut not found', 'CUT_NOT_FOUND');
        const polygons = (0, spatial_1.parseGeoJsonPolygon)(cut.geojson);
        const allLocations = await database_1.prisma.location.findMany({
            // latitude/longitude are non-nullable in schema
            select: {
                id: true,
                latitude: true,
                longitude: true,
                addresses: { select: { id: true } },
            },
        });
        // Filter locations within polygons and collect all address IDs
        const addressIds = [];
        for (const loc of allLocations) {
            if (polygons.some((p) => (0, spatial_1.isPointInPolygon)(Number(loc.latitude), Number(loc.longitude), p))) {
                addressIds.push(...loc.addresses.map((a) => a.id));
            }
        }
        const [totalAddresses, visitedAddresses, totalVisits, byOutcome] = await Promise.all([
            Promise.resolve(addressIds.length),
            database_1.prisma.canvassVisit.findMany({
                where: { addressId: { in: addressIds } },
                distinct: ['addressId'],
                select: { addressId: true },
            }),
            database_1.prisma.canvassVisit.count({ where: { addressId: { in: addressIds } } }),
            database_1.prisma.canvassVisit.groupBy({
                by: ['outcome'],
                where: { addressId: { in: addressIds } },
                _count: true,
            }),
        ]);
        const outcomeMap = {};
        for (const row of byOutcome) {
            outcomeMap[row.outcome] = row._count;
        }
        return {
            cutId,
            cutName: cut.name,
            totalLocations: totalAddresses,
            visitedLocations: visitedAddresses.length,
            completionPercentage: cut.completionPercentage,
            totalVisits,
            byOutcome: outcomeMap,
        };
    },
    async getAdminActivity(filters) {
        const { page, limit, cutId, userId, outcome } = filters;
        const skip = (page - 1) * limit;
        const where = {};
        if (userId)
            where.userId = userId;
        if (outcome)
            where.outcome = outcome;
        // If filtering by cut, we need to find addresses in that cut
        if (cutId) {
            const cut = await database_1.prisma.cut.findUnique({ where: { id: cutId } });
            if (cut) {
                const polygons = (0, spatial_1.parseGeoJsonPolygon)(cut.geojson);
                const allLocations = await database_1.prisma.location.findMany({
                    // latitude/longitude are non-nullable in schema
                    select: {
                        id: true,
                        latitude: true,
                        longitude: true,
                        addresses: { select: { id: true } },
                    },
                });
                const addressIds = [];
                for (const loc of allLocations) {
                    if (polygons.some((p) => (0, spatial_1.isPointInPolygon)(Number(loc.latitude), Number(loc.longitude), p))) {
                        addressIds.push(...loc.addresses.map((a) => a.id));
                    }
                }
                where.addressId = { in: addressIds };
            }
        }
        const [visits, total] = await Promise.all([
            database_1.prisma.canvassVisit.findMany({
                where,
                skip,
                take: limit,
                orderBy: { visitedAt: 'desc' },
                include: {
                    address: {
                        select: {
                            id: true,
                            unitNumber: true,
                            location: { select: { address: true } },
                        },
                    },
                    user: { select: { id: true, name: true, email: true } },
                },
            }),
            database_1.prisma.canvassVisit.count({ where }),
        ]);
        return {
            visits,
            pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
        };
    },
    async getVolunteers() {
        const volunteers = await database_1.prisma.canvassVisit.groupBy({
            by: ['userId'],
            _count: true,
            _max: { visitedAt: true },
        });
        const userIds = volunteers.map((v) => v.userId);
        const users = await database_1.prisma.user.findMany({
            where: { id: { in: userIds } },
            select: { id: true, name: true, email: true },
        });
        const userMap = new Map(users.map((u) => [u.id, u]));
        const sessions = await database_1.prisma.canvassSession.groupBy({
            by: ['userId'],
            where: { userId: { in: userIds } },
            _count: true,
        });
        const sessionMap = new Map(sessions.map((s) => [s.userId, s._count]));
        return volunteers.map((v) => ({
            userId: v.userId,
            name: userMap.get(v.userId)?.name ?? null,
            email: userMap.get(v.userId)?.email ?? '',
            totalVisits: v._count,
            sessions: sessionMap.get(v.userId) ?? 0,
            lastActive: v._max.visitedAt,
        }));
    },
    async getVolunteerStats(userId) {
        return this.getMyStats(userId);
    },
    async getAdminVisits(filters) {
        const { page, limit, cutId, userId, shiftId, outcome, sortBy, sortOrder } = filters;
        const skip = (page - 1) * limit;
        const where = {};
        if (userId)
            where.userId = userId;
        if (shiftId)
            where.shiftId = shiftId;
        if (outcome)
            where.outcome = outcome;
        if (cutId) {
            const cut = await database_1.prisma.cut.findUnique({ where: { id: cutId } });
            if (cut) {
                const polygons = (0, spatial_1.parseGeoJsonPolygon)(cut.geojson);
                const allLocations = await database_1.prisma.location.findMany({
                    // latitude/longitude are non-nullable in schema
                    select: {
                        id: true,
                        latitude: true,
                        longitude: true,
                        addresses: { select: { id: true } },
                    },
                });
                const addressIds = [];
                for (const loc of allLocations) {
                    if (polygons.some((p) => (0, spatial_1.isPointInPolygon)(Number(loc.latitude), Number(loc.longitude), p))) {
                        addressIds.push(...loc.addresses.map((a) => a.id));
                    }
                }
                where.addressId = { in: addressIds };
            }
        }
        const [visits, total] = await Promise.all([
            database_1.prisma.canvassVisit.findMany({
                where,
                skip,
                take: limit,
                orderBy: { [sortBy]: sortOrder },
                include: {
                    address: {
                        select: {
                            id: true,
                            unitNumber: true,
                            location: { select: { address: true } },
                        },
                    },
                    user: { select: { id: true, name: true, email: true } },
                },
            }),
            database_1.prisma.canvassVisit.count({ where }),
        ]);
        return {
            visits,
            pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
        };
    },
    async getOutcomeTrends(filters) {
        const { granularity } = filters;
        const dateTo = filters.dateTo ? new Date(filters.dateTo) : new Date();
        const dateFrom = filters.dateFrom
            ? new Date(filters.dateFrom)
            : new Date(dateTo.getTime() - 30 * 24 * 60 * 60 * 1000);
        // Ensure dateTo covers end of day
        const dateToEnd = new Date(dateTo);
        dateToEnd.setHours(23, 59, 59, 999);
        const rows = await database_1.prisma.$queryRaw `
      SELECT DATE_TRUNC(${granularity}, "visitedAt") as period,
             outcome::text as outcome,
             COUNT(*)::int as count
      FROM canvass_visits
      WHERE "visitedAt" >= ${dateFrom} AND "visitedAt" <= ${dateToEnd}
      GROUP BY period, outcome
      ORDER BY period ASC
    `;
        // Pivot rows into series: [{ date, NOT_HOME: n, SPOKE_WITH: n, ... }]
        const pivotMap = new Map();
        const totals = {};
        for (const row of rows) {
            const dateStr = row.period.toISOString().split('T')[0];
            if (!pivotMap.has(dateStr)) {
                pivotMap.set(dateStr, {});
            }
            pivotMap.get(dateStr)[row.outcome] = row.count;
            totals[row.outcome] = (totals[row.outcome] || 0) + row.count;
        }
        const series = Array.from(pivotMap.entries()).map(([date, outcomes]) => ({
            date,
            ...outcomes,
        }));
        return {
            granularity,
            dateFrom: dateFrom.toISOString().split('T')[0],
            dateTo: dateTo.toISOString().split('T')[0],
            series,
            totals,
        };
    },
    // ─── Helpers ───────────────────────────────────────────────────────
    async recalculateCutCompletion(cutId) {
        const cut = await database_1.prisma.cut.findUnique({ where: { id: cutId } });
        if (!cut)
            return;
        const polygons = (0, spatial_1.parseGeoJsonPolygon)(cut.geojson);
        const allLocations = await database_1.prisma.location.findMany({
            // latitude/longitude are non-nullable in schema
            select: {
                id: true,
                latitude: true,
                longitude: true,
                addresses: { select: { id: true } },
            },
        });
        // Filter locations within polygons and collect all address IDs
        const addressIds = [];
        for (const loc of allLocations) {
            if (polygons.some((p) => (0, spatial_1.isPointInPolygon)(Number(loc.latitude), Number(loc.longitude), p))) {
                addressIds.push(...loc.addresses.map((a) => a.id));
            }
        }
        if (addressIds.length === 0)
            return;
        const visitedAddresses = await database_1.prisma.canvassVisit.findMany({
            where: { addressId: { in: addressIds } },
            distinct: ['addressId'],
            select: { addressId: true },
        });
        const pct = Math.round((visitedAddresses.length / addressIds.length) * 100);
        await database_1.prisma.cut.update({
            where: { id: cutId },
            data: {
                completionPercentage: pct,
                lastCanvassed: new Date(),
            },
        });
    },
    async closeAbandonedSessions() {
        const cutoff = new Date(Date.now() - 12 * 60 * 60 * 1000); // 12 hours ago
        const result = await database_1.prisma.canvassSession.updateMany({
            where: {
                status: client_1.CanvassSessionStatus.ACTIVE,
                startedAt: { lt: cutoff },
            },
            data: {
                status: client_1.CanvassSessionStatus.ABANDONED,
                endedAt: new Date(),
            },
        });
        if (result.count > 0) {
            logger_1.logger.info(`Closed ${result.count} abandoned canvass sessions`);
        }
        return result.count;
    },
};
//# sourceMappingURL=canvass.service.js.map