import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler';
import { eventBus } from '../../services/event-bus.service';
import type { Prisma } from '@prisma/client';
import type {
  ListPeopleInput,
  ManagePersonInput,
  CreateContactInput,
  UpdateContactInput,
  CreateConnectionInput,
  MergeContactInput,
  GraphQueryInput,
  ActivityListInput,
  AddContactAddressInput,
  AddContactEmailInput,
  AddContactPhoneInput,
} from './people.schemas';

// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------

interface UnifiedPerson {
  id: string;
  displayName: string;
  email: string | null;
  phone: string | null;
  source: string;
  supportLevel: string | null;
  lastActivity: string | null;
  isManaged: boolean;
  contactId: string | null;
  userId: string | null;
  addressId: string | null;
  tags: string[];
  engagementScore: number | null;
  userStatus?: string;
  userRole?: string;
}

interface ActivityItem {
  id: string;
  type: string;
  title: string;
  description: string | null;
  occurredAt: string;
  metadata?: unknown;
}

interface GraphNode {
  id: string;
  contactId: string | null;
  displayName: string;
  email: string | null;
  source: string;
  supportLevel: string | null;
  tags: string[];
  engagementScore: number | null;
}

interface GraphEdge {
  id: string;
  source: string;
  target: string;
  type: string;
  label: string | null;
  isBidirectional: boolean;
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function normalizeEmail(e: string | null | undefined): string | null {
  if (!e) return null;
  return e.trim().toLowerCase();
}

function normalizePhone(p: string | null | undefined): string | null {
  if (!p) return null;
  return p.replace(/[^0-9+]/g, '') || null;
}

function buildDisplayName(first: string | null | undefined, last: string | null | undefined, email: string | null | undefined, fallback: string): string {
  if (first && last) return `${first} ${last}`;
  if (first) return first;
  if (last) return last;
  if (email) return email;
  return fallback;
}

// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------

export const peopleService = {

  // =========================================================================
  // a) LIST PEOPLE — Virtual aggregation
  // =========================================================================
  async listPeople(params: ListPeopleInput) {
    const { page, limit, search, source, supportLevel, tag, managedOnly } = params;
    const searchLower = search?.toLowerCase();

    // Fetch all sources in parallel
    const [users, addresses, campaignEmails, shiftSignups, smsEntries, orders, contacts] = await Promise.all([
      // 1. Users
      (!source || source === 'USER') ? prisma.user.findMany({
        select: { id: true, email: true, name: true, phone: true, lastLoginAt: true, contact: { select: { id: true } } },
        where: search ? {
          OR: [
            { name: { contains: search, mode: 'insensitive' } },
            { email: { contains: search, mode: 'insensitive' } },
            { phone: { contains: search, mode: 'insensitive' } },
          ],
        } : undefined,
      }) : [],

      // 2. Addresses with occupant info
      (!source || source === 'ADDRESS_OCCUPANT') ? prisma.address.findMany({
        select: {
          id: true, firstName: true, lastName: true, email: true, phone: true,
          supportLevel: true, locationId: true, updatedAt: true,
          contactAddresses: { select: { contactId: true } },
        },
        where: {
          OR: [{ firstName: { not: null } }, { lastName: { not: null } }],
          ...(search ? {
            AND: {
              OR: [
                { firstName: { contains: search, mode: 'insensitive' } },
                { lastName: { contains: search, mode: 'insensitive' } },
                { email: { contains: search, mode: 'insensitive' } },
                { phone: { contains: search, mode: 'insensitive' } },
              ],
            },
          } : {}),
        },
      }) : [],

      // 3. Distinct CampaignEmail senders
      (!source || source === 'CAMPAIGN_SENDER') ? prisma.campaignEmail.findMany({
        select: { userEmail: true, userName: true, sentAt: true },
        where: {
          userEmail: { not: null },
          ...(search ? {
            OR: [
              { userEmail: { contains: search, mode: 'insensitive' } },
              { userName: { contains: search, mode: 'insensitive' } },
            ],
          } : {}),
        },
        distinct: ['userEmail'],
        orderBy: { sentAt: 'desc' },
      }) : [],

      // 4. Distinct ShiftSignup senders
      (!source || source === 'SHIFT_SIGNUP') ? prisma.shiftSignup.findMany({
        select: { userEmail: true, userName: true, userPhone: true, signupDate: true },
        where: search ? {
          OR: [
            { userEmail: { contains: search, mode: 'insensitive' } },
            { userName: { contains: search, mode: 'insensitive' } },
            { userPhone: { contains: search, mode: 'insensitive' } },
          ],
        } : undefined,
        distinct: ['userEmail'],
        orderBy: { signupDate: 'desc' },
      }) : [],

      // 5. Distinct SmsContactListEntry by phone
      (!source || source === 'SMS_CONTACT') ? prisma.smsContactListEntry.findMany({
        select: { phone: true, name: true, email: true, createdAt: true },
        where: search ? {
          OR: [
            { phone: { contains: search, mode: 'insensitive' } },
            { name: { contains: search, mode: 'insensitive' } },
            { email: { contains: search, mode: 'insensitive' } },
          ],
        } : undefined,
        distinct: ['phone'],
        orderBy: { createdAt: 'desc' },
      }) : [],

      // 6. Distinct Order by buyerEmail
      (!source || source === 'DONATION') ? prisma.order.findMany({
        select: { buyerEmail: true, buyerName: true, createdAt: true },
        where: search ? {
          OR: [
            { buyerEmail: { contains: search, mode: 'insensitive' } },
            { buyerName: { contains: search, mode: 'insensitive' } },
          ],
        } : undefined,
        distinct: ['buyerEmail'],
        orderBy: { createdAt: 'desc' },
      }) : [],

      // All non-merged Contacts for overlay
      prisma.contact.findMany({
        where: { mergedIntoId: null },
        select: {
          id: true, displayName: true, email: true, phone: true,
          tags: true, notes: true, supportLevel: true, signRequested: true,
          emailOptOut: true, smsOptOut: true, doNotContact: true,
          primarySource: true, userId: true, createdAt: true, updatedAt: true,
          addresses: { select: { addressId: true } },
        },
      }),
    ]);

    // Build index maps for Contact overlay
    const contactByUserId = new Map<string, typeof contacts[number]>();
    const contactByEmail = new Map<string, typeof contacts[number]>();
    const contactByPhone = new Map<string, typeof contacts[number]>();
    const contactByAddressId = new Map<string, typeof contacts[number]>();

    for (const c of contacts) {
      if (c.userId) contactByUserId.set(c.userId, c);
      const ne = normalizeEmail(c.email);
      if (ne) contactByEmail.set(ne, c);
      const np = normalizePhone(c.phone);
      if (np) contactByPhone.set(np, c);
      for (const ca of c.addresses) {
        contactByAddressId.set(ca.addressId, c);
      }
    }

    // Deduplication map: keyed by normalized email or phone
    const seen = new Map<string, UnifiedPerson>();
    const people: UnifiedPerson[] = [];

    function addPerson(p: UnifiedPerson) {
      const ne = normalizeEmail(p.email);
      const np = normalizePhone(p.phone);
      const key = ne || np;
      if (key && seen.has(key)) return; // already captured
      if (key) seen.set(key, p);
      // Also register both email and phone to catch cross-key dups
      if (ne) seen.set(ne, p);
      if (np) seen.set(np, p);
      people.push(p);
    }

    function overlayContact(p: UnifiedPerson): UnifiedPerson {
      const c = (p.userId ? contactByUserId.get(p.userId) : null)
        || (p.addressId ? contactByAddressId.get(p.addressId) : null)
        || (normalizeEmail(p.email) ? contactByEmail.get(normalizeEmail(p.email)!) : null)
        || (normalizePhone(p.phone) ? contactByPhone.get(normalizePhone(p.phone)!) : null);

      if (!c) return p;

      const tags = Array.isArray(c.tags) ? (c.tags as string[]) : [];
      return {
        ...p,
        isManaged: true,
        contactId: c.id,
        tags,
        supportLevel: c.supportLevel || p.supportLevel,
        displayName: c.displayName || p.displayName,
      };
    }

    // 1. Users (highest priority)
    for (const u of users) {
      addPerson(overlayContact({
        id: `user:${u.id}`,
        displayName: u.name || u.email,
        email: u.email,
        phone: u.phone,
        source: 'USER',
        supportLevel: null,
        lastActivity: u.lastLoginAt?.toISOString() || null,
        isManaged: !!u.contact,
        contactId: u.contact?.id || null,
        userId: u.id,
        addressId: null,
        tags: [],
        engagementScore: null,
      }));
    }

    // 2. Addresses
    for (const a of addresses) {
      addPerson(overlayContact({
        id: `addr:${a.id}`,
        displayName: buildDisplayName(a.firstName, a.lastName, a.email, `Address ${a.id.slice(0, 6)}`),
        email: a.email,
        phone: a.phone,
        source: 'ADDRESS_OCCUPANT',
        supportLevel: a.supportLevel,
        lastActivity: a.updatedAt?.toISOString() || null,
        isManaged: false,
        contactId: null,
        userId: null,
        addressId: a.id,
        tags: [],
        engagementScore: null,
      }));
    }

    // 3. Campaign email senders
    for (const ce of campaignEmails) {
      if (!ce.userEmail) continue;
      addPerson(overlayContact({
        id: `cemail:${ce.userEmail}`,
        displayName: ce.userName || ce.userEmail,
        email: ce.userEmail,
        phone: null,
        source: 'CAMPAIGN_SENDER',
        supportLevel: null,
        lastActivity: ce.sentAt?.toISOString() || null,
        isManaged: false,
        contactId: null,
        userId: null,
        addressId: null,
        tags: [],
        engagementScore: null,
      }));
    }

    // 4. Shift signups
    for (const ss of shiftSignups) {
      addPerson(overlayContact({
        id: `signup:${ss.userEmail}`,
        displayName: ss.userName || ss.userEmail,
        email: ss.userEmail,
        phone: ss.userPhone,
        source: 'SHIFT_SIGNUP',
        supportLevel: null,
        lastActivity: ss.signupDate?.toISOString() || null,
        isManaged: false,
        contactId: null,
        userId: null,
        addressId: null,
        tags: [],
        engagementScore: null,
      }));
    }

    // 5. SMS contacts
    for (const sc of smsEntries) {
      addPerson(overlayContact({
        id: `sms:${sc.phone}`,
        displayName: sc.name || sc.phone,
        email: sc.email,
        phone: sc.phone,
        source: 'SMS_CONTACT',
        supportLevel: null,
        lastActivity: sc.createdAt?.toISOString() || null,
        isManaged: false,
        contactId: null,
        userId: null,
        addressId: null,
        tags: [],
        engagementScore: null,
      }));
    }

    // 6. Donors / buyers
    for (const o of orders) {
      addPerson(overlayContact({
        id: `donor:${o.buyerEmail}`,
        displayName: o.buyerName || o.buyerEmail,
        email: o.buyerEmail,
        phone: null,
        source: 'DONATION',
        supportLevel: null,
        lastActivity: o.createdAt?.toISOString() || null,
        isManaged: false,
        contactId: null,
        userId: null,
        addressId: null,
        tags: [],
        engagementScore: null,
      }));
    }

    // 7. Contacts not already captured by sources above (e.g. MANUAL contacts)
    const capturedContactIds = new Set(people.filter(p => p.contactId).map(p => p.contactId!));
    for (const c of contacts) {
      if (capturedContactIds.has(c.id)) continue;
      if (search) {
        const s = search.toLowerCase();
        const matches = c.displayName?.toLowerCase().includes(s)
          || c.email?.toLowerCase().includes(s)
          || c.phone?.includes(s);
        if (!matches) continue;
      }
      const tags = Array.isArray(c.tags) ? (c.tags as string[]) : [];
      addPerson({
        id: `contact:${c.id}`,
        displayName: c.displayName,
        email: c.email,
        phone: c.phone,
        source: c.primarySource,
        supportLevel: c.supportLevel,
        lastActivity: c.updatedAt?.toISOString() || null,
        isManaged: true,
        contactId: c.id,
        userId: c.userId,
        addressId: null,
        tags,
        engagementScore: null,
      });
    }

    // Apply post-aggregation filters
    let filtered = people;

    if (source) {
      filtered = filtered.filter(p => p.source === source);
    }

    if (supportLevel) {
      filtered = filtered.filter(p => p.supportLevel === supportLevel);
    }

    if (tag) {
      filtered = filtered.filter(p => p.tags.includes(tag));
    }

    if (managedOnly) {
      filtered = filtered.filter(p => p.isManaged);
    }

    // Sort by display name
    filtered.sort((a, b) => a.displayName.localeCompare(b.displayName));

    // Paginate
    const total = filtered.length;
    const totalPages = Math.ceil(total / limit);
    const start = (page - 1) * limit;
    const paged = filtered.slice(start, start + limit);

    return {
      people: paged,
      pagination: { page, limit, total, totalPages },
    };
  },

  // =========================================================================
  // b) GET PERSON DETAIL
  // =========================================================================
  async getPersonDetail(type: string, id: string) {
    let person: Partial<UnifiedPerson> = {};
    let contact: Awaited<ReturnType<typeof prisma.contact.findUnique>> | null = null;

    if (type === 'user') {
      const user = await prisma.user.findUnique({
        where: { id },
        select: { id: true, email: true, name: true, phone: true, lastLoginAt: true, status: true, role: true, contact: true },
      });
      if (!user) throw new AppError(404, 'User not found', 'NOT_FOUND');
      contact = user.contact;
      person = {
        id: `user:${user.id}`,
        displayName: user.name || user.email,
        email: user.email,
        phone: user.phone,
        source: 'USER',
        userId: user.id,
        lastActivity: user.lastLoginAt?.toISOString() || null,
        isManaged: !!contact,
        contactId: contact?.id || null,
        userStatus: user.status,
        userRole: user.role,
      };
    } else if (type === 'addr') {
      const addr = await prisma.address.findUnique({
        where: { id },
        select: {
          id: true, firstName: true, lastName: true, email: true, phone: true,
          supportLevel: true, locationId: true, updatedAt: true,
          location: { select: { address: true } },
          contactAddresses: { select: { contact: true } },
        },
      });
      if (!addr) throw new AppError(404, 'Address not found', 'NOT_FOUND');
      contact = addr.contactAddresses[0]?.contact || null;
      person = {
        id: `addr:${addr.id}`,
        displayName: buildDisplayName(addr.firstName, addr.lastName, addr.email, addr.location?.address || `Address ${addr.id.slice(0, 6)}`),
        email: addr.email,
        phone: addr.phone,
        source: 'ADDRESS_OCCUPANT',
        addressId: addr.id,
        supportLevel: addr.supportLevel,
        lastActivity: addr.updatedAt?.toISOString() || null,
        isManaged: !!contact,
        contactId: contact?.id || null,
      };
    } else if (type === 'contact') {
      contact = await prisma.contact.findUnique({
        where: { id },
        include: {
          addresses: { include: { address: true } },
          emails: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
          phones: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
          user: { select: { id: true, email: true, name: true } },
        },
      });
      if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
      person = {
        id: `contact:${contact.id}`,
        displayName: contact.displayName,
        email: contact.email,
        phone: contact.phone,
        source: contact.primarySource,
        userId: contact.userId,
        isManaged: true,
        contactId: contact.id,
        tags: Array.isArray(contact.tags) ? (contact.tags as string[]) : [],
        supportLevel: contact.supportLevel,
      };
    } else if (type === 'cemail') {
      // Campaign email sender — id is the email address
      const ce = await prisma.campaignEmail.findFirst({
        where: { userEmail: { equals: id, mode: 'insensitive' } },
        orderBy: { sentAt: 'desc' },
      });
      if (!ce) throw new AppError(404, 'Campaign sender not found', 'NOT_FOUND');
      contact = await this.findContactByEmail(id);
      person = {
        id: `cemail:${ce.userEmail}`,
        displayName: ce.userName || ce.userEmail || id,
        email: ce.userEmail,
        phone: null,
        source: 'CAMPAIGN_SENDER',
        lastActivity: ce.sentAt?.toISOString() || null,
        isManaged: !!contact,
        contactId: contact?.id || null,
      };
    } else if (type === 'signup') {
      // Shift signup — id is the email address
      const ss = await prisma.shiftSignup.findFirst({
        where: { userEmail: { equals: id, mode: 'insensitive' } },
        orderBy: { signupDate: 'desc' },
      });
      if (!ss) throw new AppError(404, 'Shift signup not found', 'NOT_FOUND');
      contact = await this.findContactByEmail(id);
      person = {
        id: `signup:${ss.userEmail}`,
        displayName: ss.userName || ss.userEmail,
        email: ss.userEmail,
        phone: ss.userPhone,
        source: 'SHIFT_SIGNUP',
        lastActivity: ss.signupDate?.toISOString() || null,
        isManaged: !!contact,
        contactId: contact?.id || null,
      };
    } else if (type === 'sms') {
      // SMS contact — id is the phone number
      const sc = await prisma.smsContactListEntry.findFirst({
        where: { phone: id },
        orderBy: { createdAt: 'desc' },
      });
      if (!sc) throw new AppError(404, 'SMS contact not found', 'NOT_FOUND');
      contact = await this.findContactByPhone(id);
      person = {
        id: `sms:${sc.phone}`,
        displayName: sc.name || sc.phone,
        email: sc.email,
        phone: sc.phone,
        source: 'SMS_CONTACT',
        lastActivity: sc.createdAt?.toISOString() || null,
        isManaged: !!contact,
        contactId: contact?.id || null,
      };
    } else if (type === 'donor') {
      // Donor / buyer — id is the email address
      const order = await prisma.order.findFirst({
        where: { buyerEmail: { equals: id, mode: 'insensitive' } },
        orderBy: { createdAt: 'desc' },
      });
      if (!order) throw new AppError(404, 'Donor not found', 'NOT_FOUND');
      contact = await this.findContactByEmail(id);
      person = {
        id: `donor:${order.buyerEmail}`,
        displayName: order.buyerName || order.buyerEmail,
        email: order.buyerEmail,
        phone: null,
        source: 'DONATION',
        lastActivity: order.createdAt?.toISOString() || null,
        isManaged: !!contact,
        contactId: contact?.id || null,
      };
    } else {
      throw new AppError(400, 'Invalid person type', 'INVALID_TYPE');
    }

    // Compute engagement
    const engagement = await this.getEngagementSummary(
      person.email || null,
      person.phone || null,
      person.userId || null,
    );

    return { person, contact, engagement };
  },

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

  /** Create initial ContactEmail/ContactPhone junction records for a new Contact */
  async createInitialEmailPhone(contactId: string, email: string | null, phone: string | null) {
    try {
      if (email) {
        await prisma.contactEmail.create({
          data: { contactId, email: email.trim().toLowerCase(), isPrimary: true },
        });
      }
      if (phone) {
        const normalized = phone.replace(/[^0-9+]/g, '') || phone.trim();
        if (normalized) {
          await prisma.contactPhone.create({
            data: { contactId, phone: normalized, isPrimary: true },
          });
        }
      }
    } catch (err: unknown) {
      logger.warn('Failed to create initial email/phone junction records:', err);
    }
  },
  async findContactByEmail(email: string) {
    const normalized = normalizeEmail(email);
    if (!normalized) return null;
    // Try denormalized field first (fast path)
    const byField = await prisma.contact.findFirst({
      where: { email: { equals: normalized, mode: 'insensitive' }, mergedIntoId: null },
    });
    if (byField) return byField;
    // Fallback: search junction table for non-primary emails
    const link = await prisma.contactEmail.findFirst({
      where: { email: { equals: normalized, mode: 'insensitive' }, contact: { mergedIntoId: null } },
      include: { contact: true },
    });
    return link?.contact || null;
  },

  async findContactByPhone(phone: string) {
    const normalized = normalizePhone(phone);
    if (!normalized) return null;
    const byField = await prisma.contact.findFirst({
      where: { phone: normalized, mergedIntoId: null },
    });
    if (byField) return byField;
    // Fallback: search junction table
    const link = await prisma.contactPhone.findFirst({
      where: { phone: normalized, contact: { mergedIntoId: null } },
      include: { contact: true },
    });
    return link?.contact || null;
  },

  // =========================================================================
  // c) ENGAGEMENT SUMMARY
  // =========================================================================
  async getEngagementSummary(email: string | null, phone: string | null, userId: string | null) {
    const identifier = normalizeEmail(email) || normalizePhone(phone) || userId || 'unknown';
    const cacheKey = `people:engagement:${identifier}`;

    // Check Redis cache
    try {
      const cached = await redis.get(cacheKey);
      if (cached) return JSON.parse(cached);
    } catch {
      // Redis unavailable — proceed without cache
    }

    // Query modules in parallel
    const [emailCount, responseCount, shiftCount, canvassCount, orderStats, smsMessageCount, smsConversationCount, canvassSessionCount, videoCount] = await Promise.all([
      // Campaign emails sent
      email
        ? prisma.campaignEmail.count({ where: { userEmail: { equals: email, mode: 'insensitive' } } })
        : 0,

      // Representative responses submitted
      email
        ? prisma.representativeResponse.count({ where: { submittedByEmail: { equals: email, mode: 'insensitive' } } })
        : 0,

      // Shift signups
      email
        ? prisma.shiftSignup.count({ where: { userEmail: { equals: email, mode: 'insensitive' } } })
        : 0,

      // Canvass visits
      userId
        ? prisma.canvassVisit.count({ where: { userId } })
        : 0,

      // Orders: sum and count
      email
        ? prisma.order.aggregate({
            where: { buyerEmail: { equals: email, mode: 'insensitive' }, status: 'COMPLETED' },
            _sum: { amountCAD: true },
            _count: true,
          })
        : { _sum: { amountCAD: null }, _count: 0 },

      // SMS messages received
      phone
        ? prisma.smsMessage.count({ where: { phone: normalizePhone(phone)!, direction: 'INBOUND' } })
        : 0,

      // SMS conversations
      phone
        ? prisma.smsConversation.count({ where: { phone: normalizePhone(phone)! } })
        : 0,

      // Canvass sessions
      userId
        ? prisma.canvassSession.count({ where: { userId } })
        : 0,

      // Video views
      userId
        ? prisma.videoView.count({ where: { userId } })
        : 0,
    ]);

    const donationTotal = orderStats._sum.amountCAD || 0;
    const donationCount = orderStats._count;

    // Calculate engagement score (0-100, weighted)
    // Emails 10%, responses 15%, shifts 15%, canvass 20%, donations 15%, sms 10%, media 5%, recency 10%
    const cap = (val: number, max: number) => Math.min(val / max, 1);

    const emailScore = cap(emailCount, 10) * 10;
    const responseScore = cap(responseCount, 5) * 15;
    const shiftScore = cap(shiftCount, 5) * 15;
    const canvassScore = cap(canvassCount, 20) * 20;
    const donationScore = cap(donationCount, 5) * 15;
    const smsScore = cap(smsMessageCount, 20) * 10;
    const mediaScore = cap(videoCount, 10) * 5;

    // Recency + first-seen: check activity dates across modules
    let recencyScore = 0;
    const dateChecks: Promise<{ first: Date | null; last: Date | null }>[] = [];

    if (email) {
      dateChecks.push(
        Promise.all([
          prisma.campaignEmail.findFirst({
            where: { userEmail: { equals: email, mode: 'insensitive' } },
            orderBy: { sentAt: 'asc' },
            select: { sentAt: true },
          }),
          prisma.campaignEmail.findFirst({
            where: { userEmail: { equals: email, mode: 'insensitive' } },
            orderBy: { sentAt: 'desc' },
            select: { sentAt: true },
          }),
        ]).then(([first, last]) => ({ first: first?.sentAt || null, last: last?.sentAt || null })),
      );
    }
    if (userId) {
      dateChecks.push(
        Promise.all([
          prisma.canvassVisit.findFirst({
            where: { userId },
            orderBy: { visitedAt: 'asc' },
            select: { visitedAt: true },
          }),
          prisma.canvassVisit.findFirst({
            where: { userId },
            orderBy: { visitedAt: 'desc' },
            select: { visitedAt: true },
          }),
        ]).then(([first, last]) => ({ first: first?.visitedAt || null, last: last?.visitedAt || null })),
      );
    }

    const dateResults = await Promise.all(dateChecks);
    const allFirstDates = dateResults.map(d => d.first).filter(Boolean) as Date[];
    const allLastDates = dateResults.map(d => d.last).filter(Boolean) as Date[];

    const firstSeen = allFirstDates.length > 0
      ? new Date(Math.min(...allFirstDates.map(d => d.getTime())))
      : null;
    const lastSeen = allLastDates.length > 0
      ? new Date(Math.max(...allLastDates.map(d => d.getTime())))
      : null;

    if (lastSeen) {
      const daysSince = (Date.now() - lastSeen.getTime()) / (1000 * 60 * 60 * 24);
      if (daysSince <= 7) recencyScore = 10;
      else if (daysSince <= 30) recencyScore = 7;
      else if (daysSince <= 90) recencyScore = 4;
      else if (daysSince <= 365) recencyScore = 2;
    }

    const totalScore = Math.round(emailScore + responseScore + shiftScore + canvassScore + donationScore + smsScore + mediaScore + recencyScore);

    const summary = {
      influence: {
        emailsSent: emailCount,
        responsesSubmitted: responseCount,
        campaignsParticipated: [] as string[],
      },
      map: {
        shiftsSignedUp: shiftCount,
        canvassVisits: canvassCount,
        canvassSessions: canvassSessionCount,
      },
      payments: {
        totalDonatedCAD: Number(donationTotal),
        donationCount,
      },
      sms: {
        conversations: smsConversationCount,
        messagesReceived: smsMessageCount,
      },
      media: {
        videoViews: videoCount,
      },
      overall: {
        engagementScore: totalScore,
        firstSeen: firstSeen?.toISOString() || null,
        lastSeen: lastSeen?.toISOString() || null,
      },
    };

    // Cache for 5 minutes
    try {
      await redis.set(cacheKey, JSON.stringify(summary), 'EX', 300);
    } catch {
      // Redis write failure — non-critical
    }

    return summary;
  },

  // =========================================================================
  // c2) CREATE USER FROM CONTACT
  // =========================================================================
  async createUserFromContact(
    contactId: string,
    input: { password: string; role: string; sendWelcomeEmail: boolean },
    adminUserId: string,
  ) {
    const contact = await prisma.contact.findUnique({ where: { id: contactId } });
    if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
    if (!contact.email) throw new AppError(400, 'Contact has no email address', 'NO_EMAIL');
    if (contact.mergedIntoId) throw new AppError(400, 'Contact has been merged', 'MERGED_CONTACT');
    if (contact.userId) throw new AppError(409, 'Contact is already linked to a user account', 'ALREADY_LINKED');

    // Check if a user with that email already exists
    const existingUser = await prisma.user.findUnique({ where: { email: contact.email } });
    if (existingUser) {
      // Auto-link the existing user to this contact if their contact slot is empty
      const existingContact = await prisma.contact.findFirst({
        where: { userId: existingUser.id, mergedIntoId: null },
      });
      if (!existingContact) {
        await prisma.contact.update({
          where: { id: contactId },
          data: { userId: existingUser.id },
        });

        // Log activity
        await prisma.contactActivity.create({
          data: {
            contactId,
            type: 'NOTE_ADDED',
            title: 'Linked to existing user account',
            description: `Auto-linked to existing user ${existingUser.email} (ID: ${existingUser.id})`,
          },
        });

        return { user: existingUser, action: 'linked' as const, contactId };
      }
      throw new AppError(409, 'A user with that email already exists and is linked to another contact', 'EMAIL_EXISTS');
    }

    // Create user via usersService (handles password hashing, provisioning hooks)
    const { usersService } = await import('../users/users.service');
    const user = await usersService.create({
      email: contact.email,
      password: input.password,
      name: contact.displayName,
      phone: contact.phone || undefined,
      role: input.role as any,
      roles: [input.role as any],
      status: 'ACTIVE',
    });

    // Link contact to the new user
    await prisma.contact.update({
      where: { id: contactId },
      data: { userId: user.id },
    });

    // Log activity
    await prisma.contactActivity.create({
      data: {
        contactId,
        type: 'NOTE_ADDED',
        title: 'User account created',
        description: `User account created by admin with role ${input.role}`,
      },
    });

    // Optionally send welcome email
    if (input.sendWelcomeEmail && contact.email) {
      try {
        const { emailService } = await import('../../services/email.service');
        const { env } = await import('../../config/env');
        const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
        await emailService.sendAccountApprovedEmail({
          recipientEmail: contact.email,
          recipientName: contact.displayName,
          loginUrl: `${adminUrl}/login`,
        });
      } catch (err) {
        logger.warn('Failed to send welcome email for new user from contact:', err);
      }
    }

    return { user, action: 'created' as const, contactId };
  },

  // =========================================================================
  // d) MANAGE CONTACT — Promote virtual person
  // =========================================================================
  async manageContact(input: ManagePersonInput, createdByUserId: string) {
    const { sourceType, sourceId, displayName, firstName, lastName, email, phone } = input;

    if (sourceType === 'user') {
      const user = await prisma.user.findUnique({ where: { id: sourceId }, select: { id: true, email: true, name: true, phone: true, contact: true } });
      if (!user) throw new AppError(404, 'User not found', 'NOT_FOUND');
      if (user.contact) throw new AppError(409, 'User is already a managed contact', 'ALREADY_MANAGED');

      const nameParts = (user.name || '').split(' ');
      const contact = await prisma.contact.create({
        data: {
          displayName: displayName || user.name || user.email,
          firstName: firstName || nameParts[0] || null,
          lastName: lastName || nameParts.slice(1).join(' ') || null,
          email: email || user.email,
          phone: phone || user.phone,
          primarySource: 'USER',
          userId: user.id,
          createdByUserId,
          tags: [] as unknown as Prisma.InputJsonValue,
        },
      });
      await this.createInitialEmailPhone(contact.id, contact.email, contact.phone);
      return contact;

    } else if (sourceType === 'addr') {
      const addr = await prisma.address.findUnique({
        where: { id: sourceId },
        select: { id: true, firstName: true, lastName: true, email: true, phone: true, supportLevel: true, contactAddresses: { select: { contactId: true } } },
      });
      if (!addr) throw new AppError(404, 'Address not found', 'NOT_FOUND');
      if (addr.contactAddresses.length > 0) throw new AppError(409, 'Address is already linked to a managed contact', 'ALREADY_MANAGED');

      const contact = await prisma.contact.create({
        data: {
          displayName: displayName || buildDisplayName(addr.firstName, addr.lastName, addr.email, `Address ${sourceId.slice(0, 6)}`),
          firstName: firstName || addr.firstName,
          lastName: lastName || addr.lastName,
          email: email || addr.email,
          phone: phone || addr.phone,
          supportLevel: addr.supportLevel,
          primarySource: 'ADDRESS_OCCUPANT',
          createdByUserId,
          tags: [] as unknown as Prisma.InputJsonValue,
          addresses: {
            create: { addressId: sourceId, isPrimary: true },
          },
        },
      });
      await this.createInitialEmailPhone(contact.id, contact.email, contact.phone);
      return contact;

    } else if (sourceType === 'contact') {
      // Re-fetch or validate existing contact
      const existing = await prisma.contact.findUnique({ where: { id: sourceId } });
      if (!existing) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
      return existing;

    } else if (['donor', 'cemail', 'signup', 'sms'].includes(sourceType)) {
      // Virtual-only sources: create Contact from provided data
      const sourceMap: Record<string, string> = {
        donor: 'DONATION',
        cemail: 'CAMPAIGN_SENDER',
        signup: 'SHIFT_SIGNUP',
        sms: 'SMS_CONTACT',
      };
      const contact = await prisma.contact.create({
        data: {
          displayName: displayName || email || phone || sourceId,
          firstName: firstName || null,
          lastName: lastName || null,
          email: email || (sourceType !== 'sms' ? sourceId : null),
          phone: phone || (sourceType === 'sms' ? sourceId : null),
          primarySource: sourceMap[sourceType] as any,
          createdByUserId,
          tags: [] as unknown as Prisma.InputJsonValue,
        },
      });
      await this.createInitialEmailPhone(contact.id, contact.email, contact.phone);
      return contact;

    } else {
      throw new AppError(400, 'Invalid sourceType', 'INVALID_TYPE');
    }
  },

  // =========================================================================
  // d2) CREATE CONTACT — Direct manual creation
  // =========================================================================
  async createContact(input: CreateContactInput, createdByUserId: string) {
    const contact = await prisma.contact.create({
      data: {
        displayName: input.displayName,
        firstName: input.firstName || null,
        lastName: input.lastName || null,
        email: input.email || null,
        phone: input.phone || null,
        tags: (input.tags || []) as unknown as Prisma.InputJsonValue,
        notes: input.notes || null,
        supportLevel: input.supportLevel || null,
        primarySource: 'MANUAL',
        createdByUserId,
      },
    });

    // Create initial ContactEmail/ContactPhone junction records
    if (input.email) {
      await prisma.contactEmail.create({
        data: { contactId: contact.id, email: input.email.trim().toLowerCase(), isPrimary: true },
      }).catch((err: unknown) => logger.warn('Failed to create initial ContactEmail:', err));
    }
    if (input.phone) {
      const normalizedPhone = input.phone.replace(/[^0-9+]/g, '') || input.phone.trim();
      await prisma.contactPhone.create({
        data: { contactId: contact.id, phone: normalizedPhone, isPrimary: true },
      }).catch((err: unknown) => logger.warn('Failed to create initial ContactPhone:', err));
    }

    // Fire-and-forget: tag count + Listmonk sync for initial tags
    const initialTags = input.tags || [];
    if (initialTags.length > 0) {
      import('./tags.service').then(({ tagsService }) => {
        tagsService.updateTagCounts(initialTags, []).catch(err =>
          logger.warn('Failed to update tag counts on create:', err)
        );
      });

      if (input.email) {
        eventBus.publish('contact.tags.changed', {
          email: input.email!,
          name: contact.displayName || '',
          contactId: contact.id,
          addedTags: initialTags,
          removedTags: [],
        });
      }
    }

    return contact;
  },

  // =========================================================================
  // e) UPDATE CONTACT
  // =========================================================================
  async updateContact(id: string, data: UpdateContactInput) {
    const existing = await prisma.contact.findUnique({ where: { id } });
    if (!existing) throw new AppError(404, 'Contact not found', 'NOT_FOUND');

    const updateData: Prisma.ContactUncheckedUpdateInput = {};

    if (data.displayName !== undefined) updateData.displayName = data.displayName;
    if (data.firstName !== undefined) updateData.firstName = data.firstName;
    if (data.lastName !== undefined) updateData.lastName = data.lastName;
    if (data.email !== undefined) updateData.email = data.email === '' ? null : data.email;
    if (data.phone !== undefined) updateData.phone = data.phone;
    if (data.notes !== undefined) updateData.notes = data.notes;
    if (data.supportLevel !== undefined) updateData.supportLevel = data.supportLevel;
    if (data.signRequested !== undefined) updateData.signRequested = data.signRequested;
    if (data.emailOptOut !== undefined) updateData.emailOptOut = data.emailOptOut;
    if (data.smsOptOut !== undefined) updateData.smsOptOut = data.smsOptOut;
    if (data.doNotContact !== undefined) updateData.doNotContact = data.doNotContact;
    if (data.tags !== undefined) updateData.tags = data.tags as unknown as Prisma.InputJsonValue;

    const contact = await prisma.contact.update({
      where: { id },
      data: updateData,
    });

    // Sync primary ContactEmail/ContactPhone when denormalized field changes
    if (data.email !== undefined) {
      const newEmail = data.email === '' ? null : data.email;
      if (newEmail) {
        const normalized = newEmail.trim().toLowerCase();
        await prisma.contactEmail.upsert({
          where: { contactId_email: { contactId: id, email: normalized } },
          create: { contactId: id, email: normalized, isPrimary: true },
          update: { isPrimary: true },
        }).catch((err: unknown) => logger.warn('Failed to upsert primary ContactEmail:', err));
      }
    }
    if (data.phone !== undefined && data.phone) {
      const normalized = data.phone.replace(/[^0-9+]/g, '') || data.phone.trim();
      if (normalized) {
        await prisma.contactPhone.upsert({
          where: { contactId_phone: { contactId: id, phone: normalized } },
          create: { contactId: id, phone: normalized, isPrimary: true },
          update: { isPrimary: true },
        }).catch((err: unknown) => logger.warn('Failed to upsert primary ContactPhone:', err));
      }
    }

    // Fire-and-forget: tag count updates + Listmonk sync on tag changes
    if (data.tags !== undefined) {
      const oldTags = Array.isArray(existing.tags) ? (existing.tags as string[]) : [];
      const newTags = data.tags || [];
      const addedTags = newTags.filter(t => !oldTags.includes(t));
      const removedTags = oldTags.filter(t => !newTags.includes(t));

      if (addedTags.length > 0 || removedTags.length > 0) {
        import('./tags.service').then(({ tagsService }) => {
          tagsService.updateTagCounts(addedTags, removedTags).catch(err =>
            logger.warn('Failed to update tag counts:', err)
          );
        });

        const email = (data.email !== undefined ? (data.email === '' ? null : data.email) : existing.email);
        if (email) {
          eventBus.publish('contact.tags.changed', {
            email,
            name: contact.displayName || '',
            contactId: existing.id,
            addedTags,
            removedTags,
          });
        }
      }
    }

    return contact;
  },

  // =========================================================================
  // f) DELETE CONTACT
  // =========================================================================
  async deleteContact(id: string) {
    const existing = await prisma.contact.findUnique({ where: { id } });
    if (!existing) throw new AppError(404, 'Contact not found', 'NOT_FOUND');

    await prisma.contact.delete({ where: { id } });
  },

  // =========================================================================
  // g) MERGE CONTACTS
  // =========================================================================
  async mergeContacts(targetId: string, input: MergeContactInput) {
    const target = await prisma.contact.findUnique({ where: { id: targetId } });
    if (!target) throw new AppError(404, 'Target contact not found', 'NOT_FOUND');

    // Resolve or create source contact
    let sourceContact: Awaited<ReturnType<typeof prisma.contact.findUnique>>;

    if (input.sourceType === 'contact') {
      sourceContact = await prisma.contact.findUnique({ where: { id: input.sourceId } });
      if (!sourceContact) throw new AppError(404, 'Source contact not found', 'NOT_FOUND');
    } else if (input.sourceType === 'user') {
      // Auto-manage the user first
      const user = await prisma.user.findUnique({ where: { id: input.sourceId }, select: { id: true, email: true, name: true, phone: true, contact: true } });
      if (!user) throw new AppError(404, 'Source user not found', 'NOT_FOUND');
      if (user.contact) {
        sourceContact = user.contact;
      } else {
        sourceContact = await prisma.contact.create({
          data: {
            displayName: user.name || user.email,
            email: user.email,
            phone: user.phone,
            primarySource: 'USER',
            userId: user.id,
            tags: [] as unknown as Prisma.InputJsonValue,
          },
        });
      }
    } else if (input.sourceType === 'addr') {
      const addr = await prisma.address.findUnique({
        where: { id: input.sourceId },
        select: { id: true, firstName: true, lastName: true, email: true, phone: true, contactAddresses: { select: { contact: true } } },
      });
      if (!addr) throw new AppError(404, 'Source address not found', 'NOT_FOUND');
      if (addr.contactAddresses[0]?.contact) {
        sourceContact = addr.contactAddresses[0].contact;
      } else {
        sourceContact = await prisma.contact.create({
          data: {
            displayName: buildDisplayName(addr.firstName, addr.lastName, addr.email, `Address ${input.sourceId.slice(0, 6)}`),
            firstName: addr.firstName,
            lastName: addr.lastName,
            email: addr.email,
            phone: addr.phone,
            primarySource: 'ADDRESS_OCCUPANT',
            tags: [] as unknown as Prisma.InputJsonValue,
            addresses: { create: { addressId: input.sourceId, isPrimary: false } },
          },
        });
      }
    } else {
      throw new AppError(400, 'Invalid sourceType', 'INVALID_TYPE');
    }

    if (!sourceContact) throw new AppError(404, 'Source contact not found after resolution', 'NOT_FOUND');
    if (sourceContact.id === targetId) throw new AppError(400, 'Cannot merge a contact into itself', 'SELF_MERGE');

    // Compute tag union before transaction (needed for post-merge hooks)
    const preTargetTags = Array.isArray(target.tags) ? (target.tags as string[]) : [];
    const preSourceTags = Array.isArray(sourceContact!.tags) ? (sourceContact!.tags as string[]) : [];
    const preMergedTags = [...new Set([...preTargetTags, ...preSourceTags])];

    // Perform merge in a transaction
    await prisma.$transaction(async (tx) => {
      // Transfer ContactAddresses to target
      await tx.contactAddress.updateMany({
        where: { contactId: sourceContact!.id },
        data: { contactId: targetId },
      });

      // Transfer ContactEmails to target (skip duplicates, demote to non-primary)
      const sourceEmails = await tx.contactEmail.findMany({ where: { contactId: sourceContact!.id } });
      for (const se of sourceEmails) {
        const exists = await tx.contactEmail.findUnique({
          where: { contactId_email: { contactId: targetId, email: se.email } },
        });
        if (!exists) {
          await tx.contactEmail.update({
            where: { id: se.id },
            data: { contactId: targetId, isPrimary: false },
          });
        } else {
          await tx.contactEmail.delete({ where: { id: se.id } });
        }
      }

      // Transfer ContactPhones to target (skip duplicates, demote to non-primary)
      const sourcePhones = await tx.contactPhone.findMany({ where: { contactId: sourceContact!.id } });
      for (const sp of sourcePhones) {
        const exists = await tx.contactPhone.findUnique({
          where: { contactId_phone: { contactId: targetId, phone: sp.phone } },
        });
        if (!exists) {
          await tx.contactPhone.update({
            where: { id: sp.id },
            data: { contactId: targetId, isPrimary: false },
          });
        } else {
          await tx.contactPhone.delete({ where: { id: sp.id } });
        }
      }

      // Transfer ContactConnections (from)
      const connectionsFrom = await tx.contactConnection.findMany({ where: { fromContactId: sourceContact!.id } });
      for (const conn of connectionsFrom) {
        // Skip if connection to target already exists for this type
        const exists = await tx.contactConnection.findUnique({
          where: { fromContactId_toContactId_type: { fromContactId: targetId, toContactId: conn.toContactId, type: conn.type } },
        });
        if (!exists && conn.toContactId !== targetId) {
          await tx.contactConnection.update({ where: { id: conn.id }, data: { fromContactId: targetId } });
        } else {
          await tx.contactConnection.delete({ where: { id: conn.id } });
        }
      }

      // Transfer ContactConnections (to)
      const connectionsTo = await tx.contactConnection.findMany({ where: { toContactId: sourceContact!.id } });
      for (const conn of connectionsTo) {
        const exists = await tx.contactConnection.findUnique({
          where: { fromContactId_toContactId_type: { fromContactId: conn.fromContactId, toContactId: targetId, type: conn.type } },
        });
        if (!exists && conn.fromContactId !== targetId) {
          await tx.contactConnection.update({ where: { id: conn.id }, data: { toContactId: targetId } });
        } else {
          await tx.contactConnection.delete({ where: { id: conn.id } });
        }
      }

      // Transfer ContactActivities
      await tx.contactActivity.updateMany({
        where: { contactId: sourceContact!.id },
        data: { contactId: targetId },
      });

      // Merge tags (union) — uses pre-computed values
      const mergedTags = preMergedTags;

      // Apply keepFields logic: if source preferred for a field, overwrite target
      const keepFields = input.keepFields || {};
      const fieldUpdate: Prisma.ContactUncheckedUpdateInput = {
        tags: mergedTags as unknown as Prisma.InputJsonValue,
      };

      if (keepFields.displayName === 'source' && sourceContact!.displayName) fieldUpdate.displayName = sourceContact!.displayName;
      if (keepFields.email === 'source' && sourceContact!.email) fieldUpdate.email = sourceContact!.email;
      if (keepFields.phone === 'source' && sourceContact!.phone) fieldUpdate.phone = sourceContact!.phone;
      if (keepFields.firstName === 'source' && sourceContact!.firstName) fieldUpdate.firstName = sourceContact!.firstName;
      if (keepFields.lastName === 'source' && sourceContact!.lastName) fieldUpdate.lastName = sourceContact!.lastName;
      if (keepFields.notes === 'source' && sourceContact!.notes) fieldUpdate.notes = sourceContact!.notes;

      // Fill in blanks from source regardless
      if (!target.email && sourceContact!.email && keepFields.email !== 'target') fieldUpdate.email = sourceContact!.email;
      if (!target.phone && sourceContact!.phone && keepFields.phone !== 'target') fieldUpdate.phone = sourceContact!.phone;
      if (!target.firstName && sourceContact!.firstName && keepFields.firstName !== 'target') fieldUpdate.firstName = sourceContact!.firstName;
      if (!target.lastName && sourceContact!.lastName && keepFields.lastName !== 'target') fieldUpdate.lastName = sourceContact!.lastName;

      // Transfer userId if source has one and target doesn't
      if (sourceContact!.userId && !target.userId) {
        fieldUpdate.userId = sourceContact!.userId;
      }

      await tx.contact.update({ where: { id: targetId }, data: fieldUpdate });

      // Mark source as merged
      await tx.contact.update({
        where: { id: sourceContact!.id },
        data: { mergedIntoId: targetId },
      });

      // Log activity
      await tx.contactActivity.create({
        data: {
          contactId: targetId,
          type: 'CONTACT_MERGED',
          title: `Merged with ${sourceContact!.displayName}`,
          description: `Contact "${sourceContact!.displayName}" (${sourceContact!.id}) was merged into this contact.`,
          metadata: { sourceContactId: sourceContact!.id, sourceDisplayName: sourceContact!.displayName } as unknown as Prisma.InputJsonValue,
        },
      });
    });

    // Ensure primary email/phone are synced after merge
    await this.syncPrimaryEmail(targetId).catch(err => logger.warn('Failed to sync primary email after merge:', err));
    await this.syncPrimaryPhone(targetId).catch(err => logger.warn('Failed to sync primary phone after merge:', err));

    // Fire-and-forget: tag count + Listmonk sync for merged tags
    const addedToTarget = preMergedTags.filter((t: string) => !preTargetTags.includes(t));
    if (addedToTarget.length > 0) {
      import('./tags.service').then(({ tagsService }) => {
        tagsService.updateTagCounts(addedToTarget, []).catch(err =>
          logger.warn('Failed to update tag counts on merge:', err)
        );
      });

      const mergedEmail = target.email || sourceContact?.email;
      if (mergedEmail) {
        eventBus.publish('contact.tags.changed', {
          email: mergedEmail,
          name: target.displayName,
          contactId: targetId,
          addedTags: addedToTarget,
          removedTags: [],
        });
      }
    }

    // Return updated target
    return prisma.contact.findUnique({
      where: { id: targetId },
      include: { addresses: true, connectionsFrom: true, connectionsTo: true },
    });
  },

  // =========================================================================
  // h) GET CONNECTIONS
  // =========================================================================
  async getConnections(contactId: string) {
    const contact = await prisma.contact.findUnique({ where: { id: contactId } });
    if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');

    const [connectionsFrom, connectionsTo] = await Promise.all([
      prisma.contactConnection.findMany({
        where: { fromContactId: contactId },
        include: { toContact: { select: { id: true, displayName: true, email: true, phone: true, primarySource: true } } },
      }),
      prisma.contactConnection.findMany({
        where: { toContactId: contactId },
        include: { fromContact: { select: { id: true, displayName: true, email: true, phone: true, primarySource: true } } },
      }),
    ]);

    const connections = [
      ...connectionsFrom.map(c => ({
        id: c.id,
        type: c.type,
        label: c.label,
        notes: c.notes,
        isBidirectional: c.isBidirectional,
        direction: 'outgoing' as const,
        relatedContact: c.toContact,
        createdAt: c.createdAt,
      })),
      ...connectionsTo
        .filter(c => !c.isBidirectional) // bidirectional already covered from outgoing
        .map(c => ({
          id: c.id,
          type: c.type,
          label: c.label,
          notes: c.notes,
          isBidirectional: c.isBidirectional,
          direction: 'incoming' as const,
          relatedContact: c.fromContact,
          createdAt: c.createdAt,
        })),
      // Include bidirectional connections where this is the target (not already in "from")
      ...connectionsTo
        .filter(c => c.isBidirectional)
        .map(c => ({
          id: c.id,
          type: c.type,
          label: c.label,
          notes: c.notes,
          isBidirectional: c.isBidirectional,
          direction: 'bidirectional' as const,
          relatedContact: c.fromContact,
          createdAt: c.createdAt,
        })),
    ];

    return connections;
  },

  // =========================================================================
  // i) CREATE CONNECTION
  // =========================================================================
  async createConnection(contactId: string, input: CreateConnectionInput, createdByUserId: string) {
    const fromContact = await prisma.contact.findUnique({ where: { id: contactId } });
    if (!fromContact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');

    // Auto-manage the target person if needed
    let targetContactId: string;
    if (input.toPersonType === 'contact') {
      const target = await prisma.contact.findUnique({ where: { id: input.toPersonId } });
      if (!target) throw new AppError(404, 'Target contact not found', 'NOT_FOUND');
      targetContactId = target.id;
    } else {
      const managed = await this.manageContact({
        sourceType: input.toPersonType,
        sourceId: input.toPersonId,
      }, createdByUserId);
      targetContactId = managed.id;
    }

    if (targetContactId === contactId) {
      throw new AppError(400, 'Cannot create a connection to self', 'SELF_CONNECTION');
    }

    // Check for existing connection
    const existing = await prisma.contactConnection.findUnique({
      where: {
        fromContactId_toContactId_type: {
          fromContactId: contactId,
          toContactId: targetContactId,
          type: input.type,
        },
      },
    });
    if (existing) throw new AppError(409, 'Connection already exists', 'CONNECTION_EXISTS');

    const connection = await prisma.contactConnection.create({
      data: {
        fromContactId: contactId,
        toContactId: targetContactId,
        type: input.type,
        label: input.label,
        notes: input.notes,
        isBidirectional: input.isBidirectional,
      },
      include: {
        fromContact: { select: { id: true, displayName: true } },
        toContact: { select: { id: true, displayName: true } },
      },
    });

    return connection;
  },

  // =========================================================================
  // j) DELETE CONNECTION
  // =========================================================================
  async deleteConnection(id: string) {
    const conn = await prisma.contactConnection.findUnique({ where: { id } });
    if (!conn) throw new AppError(404, 'Connection not found', 'NOT_FOUND');

    await prisma.contactConnection.delete({ where: { id } });
  },

  // =========================================================================
  // k) GRAPH DATA
  // =========================================================================
  async getGraphData(params: GraphQueryInput) {
    const { center, depth, source, tag, minScore } = params;
    const MAX_NODES = 200;

    // Start from all managed contacts or a specific center
    let rootContacts: { id: string; displayName: string; email: string | null; phone: string | null; primarySource: string; supportLevel: string | null; tags: unknown; userId: string | null }[];

    if (center) {
      const c = await prisma.contact.findUnique({
        where: { id: center, mergedIntoId: null },
        select: { id: true, displayName: true, email: true, phone: true, primarySource: true, supportLevel: true, tags: true, userId: true },
      });
      if (!c) throw new AppError(404, 'Center contact not found', 'NOT_FOUND');
      rootContacts = [c];
    } else {
      const where: Prisma.ContactWhereInput = { mergedIntoId: null };
      if (source && source !== 'USER') where.primarySource = source;
      rootContacts = await prisma.contact.findMany({
        where,
        select: { id: true, displayName: true, email: true, phone: true, primarySource: true, supportLevel: true, tags: true, userId: true },
        take: MAX_NODES,
        orderBy: { updatedAt: 'desc' },
      });
    }

    const nodesMap = new Map<string, GraphNode>();
    const edges: GraphEdge[] = [];
    const visited = new Set<string>();

    // Track which user IDs already have Contact records
    const contactUserIds = new Set<string>();

    // BFS
    let frontier = rootContacts.map(c => c.id);
    let currentDepth = 0;

    // Add initial contact nodes (using unified IDs)
    for (const c of rootContacts) {
      const tags = Array.isArray(c.tags) ? (c.tags as string[]) : [];
      if (tag && !tags.includes(tag)) continue;
      if (c.userId) contactUserIds.add(c.userId);
      nodesMap.set(`contact:${c.id}`, {
        id: `contact:${c.id}`,
        contactId: c.id,
        displayName: c.displayName,
        email: c.email,
        source: c.primarySource,
        supportLevel: c.supportLevel,
        tags,
        engagementScore: null,
      });
    }

    while (currentDepth < depth && frontier.length > 0 && nodesMap.size < MAX_NODES) {
      const nextFrontier: string[] = [];

      const connections = await prisma.contactConnection.findMany({
        where: {
          OR: [
            { fromContactId: { in: frontier } },
            { toContactId: { in: frontier } },
          ],
        },
        include: {
          fromContact: { select: { id: true, displayName: true, email: true, phone: true, primarySource: true, supportLevel: true, tags: true, mergedIntoId: true, userId: true } },
          toContact: { select: { id: true, displayName: true, email: true, phone: true, primarySource: true, supportLevel: true, tags: true, mergedIntoId: true, userId: true } },
        },
      });

      for (const conn of connections) {
        if (conn.fromContact.mergedIntoId || conn.toContact.mergedIntoId) continue;

        // Add edge with unified IDs (dedup by id)
        if (!edges.find(e => e.id === conn.id)) {
          edges.push({
            id: conn.id,
            source: `contact:${conn.fromContactId}`,
            target: `contact:${conn.toContactId}`,
            type: conn.type,
            label: conn.label,
            isBidirectional: conn.isBidirectional,
          });
        }

        // Add neighbor nodes
        for (const neighbor of [conn.fromContact, conn.toContact]) {
          const nodeId = `contact:${neighbor.id}`;
          if (nodesMap.has(nodeId) || visited.has(neighbor.id)) continue;
          if (nodesMap.size >= MAX_NODES) break;

          const nTags = Array.isArray(neighbor.tags) ? (neighbor.tags as string[]) : [];
          if (tag && !nTags.includes(tag)) continue;
          if (source && source !== 'USER' && neighbor.primarySource !== source) continue;

          if (neighbor.userId) contactUserIds.add(neighbor.userId);
          nodesMap.set(nodeId, {
            id: nodeId,
            contactId: neighbor.id,
            displayName: neighbor.displayName,
            email: neighbor.email,
            source: neighbor.primarySource,
            supportLevel: neighbor.supportLevel,
            tags: nTags,
            engagementScore: null,
          });

          nextFrontier.push(neighbor.id);
        }
      }

      for (const id of frontier) visited.add(id);
      frontier = nextFrontier;
      currentDepth++;
    }

    // -----------------------------------------------------------------------
    // Include all source types (not just Contacts) so the graph reflects
    // the same universe of people shown in the table/cards views.
    // We dedup by normalized email/phone to avoid double-counting people
    // who already appear via a Contact or User node above.
    // -----------------------------------------------------------------------

    // Build a set of emails/phones already represented in the graph
    const representedEmails = new Set<string>();
    const representedPhones = new Set<string>();
    for (const n of nodesMap.values()) {
      const ne = normalizeEmail(n.email);
      if (ne) representedEmails.add(ne);
    }
    // Contacts have phones too — check rootContacts and connection neighbors
    for (const c of rootContacts) {
      const np = normalizePhone(c.phone);
      if (np) representedPhones.add(np);
    }

    function isAlreadyRepresented(email: string | null, phone: string | null): boolean {
      const ne = normalizeEmail(email);
      if (ne && representedEmails.has(ne)) return true;
      const np = normalizePhone(phone);
      if (np && representedPhones.has(np)) return true;
      return false;
    }

    function markRepresented(email: string | null, phone: string | null) {
      const ne = normalizeEmail(email);
      if (ne) representedEmails.add(ne);
      const np = normalizePhone(phone);
      if (np) representedPhones.add(np);
    }

    // Mark all existing nodes
    for (const n of nodesMap.values()) {
      markRepresented(n.email, null);
    }

    if (!center) {
      // Users (not yet represented via a Contact node)
      if ((!source || source === 'USER') && nodesMap.size < MAX_NODES) {
        const users = await prisma.user.findMany({
          select: { id: true, name: true, email: true, phone: true },
          take: MAX_NODES - nodesMap.size,
          orderBy: { createdAt: 'desc' },
        });
        for (const user of users) {
          if (contactUserIds.has(user.id)) continue;
          if (isAlreadyRepresented(user.email, user.phone)) continue;
          if (nodesMap.size >= MAX_NODES) break;
          const nodeId = `user:${user.id}`;
          nodesMap.set(nodeId, {
            id: nodeId, contactId: null,
            displayName: user.name || user.email,
            email: user.email, source: 'USER',
            supportLevel: null, tags: [], engagementScore: null,
          });
          markRepresented(user.email, user.phone);
        }
      }

      // Address occupants
      if ((!source || source === 'ADDRESS_OCCUPANT') && nodesMap.size < MAX_NODES) {
        const addresses = await prisma.address.findMany({
          select: { id: true, firstName: true, lastName: true, email: true, phone: true, supportLevel: true },
          where: { OR: [{ firstName: { not: null } }, { lastName: { not: null } }] },
          take: MAX_NODES - nodesMap.size,
        });
        for (const a of addresses) {
          if (isAlreadyRepresented(a.email, a.phone)) continue;
          if (nodesMap.size >= MAX_NODES) break;
          const nodeId = `addr:${a.id}`;
          nodesMap.set(nodeId, {
            id: nodeId, contactId: null,
            displayName: buildDisplayName(a.firstName, a.lastName, a.email, `Address ${a.id.slice(0, 6)}`),
            email: a.email, source: 'ADDRESS_OCCUPANT',
            supportLevel: a.supportLevel, tags: [], engagementScore: null,
          });
          markRepresented(a.email, a.phone);
        }
      }

      // Campaign email senders
      if ((!source || source === 'CAMPAIGN_SENDER') && nodesMap.size < MAX_NODES) {
        const campaignEmails = await prisma.campaignEmail.findMany({
          select: { userEmail: true, userName: true },
          where: { userEmail: { not: null } },
          distinct: ['userEmail'],
          take: MAX_NODES - nodesMap.size,
          orderBy: { sentAt: 'desc' },
        });
        for (const ce of campaignEmails) {
          if (!ce.userEmail) continue;
          if (isAlreadyRepresented(ce.userEmail, null)) continue;
          if (nodesMap.size >= MAX_NODES) break;
          const nodeId = `cemail:${ce.userEmail}`;
          nodesMap.set(nodeId, {
            id: nodeId, contactId: null,
            displayName: ce.userName || ce.userEmail,
            email: ce.userEmail, source: 'CAMPAIGN_SENDER',
            supportLevel: null, tags: [], engagementScore: null,
          });
          markRepresented(ce.userEmail, null);
        }
      }

      // Shift signups
      if ((!source || source === 'SHIFT_SIGNUP') && nodesMap.size < MAX_NODES) {
        const shiftSignups = await prisma.shiftSignup.findMany({
          select: { userEmail: true, userName: true, userPhone: true },
          distinct: ['userEmail'],
          take: MAX_NODES - nodesMap.size,
          orderBy: { signupDate: 'desc' },
        });
        for (const ss of shiftSignups) {
          if (isAlreadyRepresented(ss.userEmail, ss.userPhone)) continue;
          if (nodesMap.size >= MAX_NODES) break;
          const nodeId = `signup:${ss.userEmail}`;
          nodesMap.set(nodeId, {
            id: nodeId, contactId: null,
            displayName: ss.userName || ss.userEmail,
            email: ss.userEmail, source: 'SHIFT_SIGNUP',
            supportLevel: null, tags: [], engagementScore: null,
          });
          markRepresented(ss.userEmail, ss.userPhone);
        }
      }

      // SMS contacts
      if ((!source || source === 'SMS_CONTACT') && nodesMap.size < MAX_NODES) {
        const smsEntries = await prisma.smsContactListEntry.findMany({
          select: { phone: true, name: true, email: true },
          distinct: ['phone'],
          take: MAX_NODES - nodesMap.size,
          orderBy: { createdAt: 'desc' },
        });
        for (const sc of smsEntries) {
          if (isAlreadyRepresented(sc.email, sc.phone)) continue;
          if (nodesMap.size >= MAX_NODES) break;
          const nodeId = `sms:${sc.phone}`;
          nodesMap.set(nodeId, {
            id: nodeId, contactId: null,
            displayName: sc.name || sc.phone,
            email: sc.email, source: 'SMS_CONTACT',
            supportLevel: null, tags: [], engagementScore: null,
          });
          markRepresented(sc.email, sc.phone);
        }
      }

      // Donations (Orders)
      if ((!source || source === 'DONATION') && nodesMap.size < MAX_NODES) {
        const orders = await prisma.order.findMany({
          select: { buyerEmail: true, buyerName: true },
          distinct: ['buyerEmail'],
          take: MAX_NODES - nodesMap.size,
          orderBy: { createdAt: 'desc' },
        });
        for (const o of orders) {
          if (isAlreadyRepresented(o.buyerEmail, null)) continue;
          if (nodesMap.size >= MAX_NODES) break;
          const nodeId = `order:${o.buyerEmail}`;
          nodesMap.set(nodeId, {
            id: nodeId, contactId: null,
            displayName: o.buyerName || o.buyerEmail || 'Unknown',
            email: o.buyerEmail, source: 'DONATION',
            supportLevel: null, tags: [], engagementScore: null,
          });
          markRepresented(o.buyerEmail, null);
        }
      }
    }

    // Apply minScore filter if set (requires computing engagement for each node)
    let nodes = Array.from(nodesMap.values());
    if (minScore !== undefined && minScore > 0) {
      const scored = await Promise.all(
        nodes.map(async (n) => {
          const eng = await this.getEngagementSummary(n.email, null, null);
          n.engagementScore = eng.overall.engagementScore;
          return n;
        })
      );
      nodes = scored.filter(n => (n.engagementScore || 0) >= minScore);
      // Also filter edges to only include nodes that remain
      const nodeIds = new Set(nodes.map(n => n.id));
      const filteredEdges = edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target));
      return { nodes, edges: filteredEdges };
    }

    return { nodes, edges };
  },

  // =========================================================================
  // l) STATS
  // =========================================================================
  async getStats() {
    const [userCount, addressCount, campaignEmailCount, shiftSignupCount, smsCount, orderCount, manualContactCount, managedCount, withEmailCount, withPhoneCount] = await Promise.all([
      prisma.user.count(),
      prisma.address.count({ where: { OR: [{ firstName: { not: null } }, { lastName: { not: null } }] } }),
      prisma.campaignEmail.count({ where: { userEmail: { not: null } } }),
      prisma.shiftSignup.count(),
      prisma.smsContactListEntry.count(),
      prisma.order.count(),
      // Standalone contacts not linked to any source table (manually created)
      prisma.contact.count({ where: { mergedIntoId: null, primarySource: 'MANUAL' } }),
      prisma.contact.count({ where: { mergedIntoId: null } }),
      prisma.contact.count({ where: { email: { not: null }, mergedIntoId: null } }),
      prisma.contact.count({ where: { phone: { not: null }, mergedIntoId: null } }),
    ]);

    return {
      total: userCount + addressCount + campaignEmailCount + shiftSignupCount + smsCount + orderCount + manualContactCount,
      bySource: {
        USER: userCount,
        ADDRESS_OCCUPANT: addressCount,
        CAMPAIGN_SENDER: campaignEmailCount,
        SHIFT_SIGNUP: shiftSignupCount,
        SMS_CONTACT: smsCount,
        DONATION: orderCount,
        MANUAL: manualContactCount,
      },
      managed: managedCount,
      withEmail: withEmailCount,
      withPhone: withPhoneCount,
    };
  },

  // =========================================================================
  // m) DUPLICATES
  // =========================================================================
  async getDuplicates() {
    const duplicates: { personA: string; personB: string; matchType: string; confidence: number }[] = [];

    // Find emails that appear in multiple source tables
    // Get all User emails
    const userEmails = await prisma.user.findMany({ select: { id: true, email: true, name: true } });
    const userEmailSet = new Map(userEmails.map(u => [normalizeEmail(u.email)!, { id: u.id, name: u.name, email: u.email }]));

    // Check addresses for matching user emails
    const addressesWithEmail = await prisma.address.findMany({
      where: { email: { not: null } },
      select: { id: true, email: true, firstName: true, lastName: true },
    });

    for (const addr of addressesWithEmail) {
      const ne = normalizeEmail(addr.email);
      if (ne && userEmailSet.has(ne)) {
        const user = userEmailSet.get(ne)!;
        duplicates.push({
          personA: `user:${user.id}`,
          personB: `addr:${addr.id}`,
          matchType: 'email',
          confidence: 95,
        });
      }
    }

    // Check shift signups for matching user emails
    const signupEmails = await prisma.shiftSignup.findMany({
      distinct: ['userEmail'],
      select: { userEmail: true, userName: true },
    });

    for (const su of signupEmails) {
      const ne = normalizeEmail(su.userEmail);
      if (ne && userEmailSet.has(ne)) continue; // User match is expected (linked accounts)
      // Check if same email in addresses
      const matchAddr = addressesWithEmail.find(a => normalizeEmail(a.email) === ne);
      if (matchAddr) {
        duplicates.push({
          personA: `signup:${su.userEmail}`,
          personB: `addr:${matchAddr.id}`,
          matchType: 'email',
          confidence: 85,
        });
      }
    }

    // Check for similar names across tables (User name matches Address firstName+lastName)
    for (const user of userEmails) {
      if (!user.name) continue;
      const userNameLower = user.name.toLowerCase().trim();

      for (const addr of addressesWithEmail) {
        if (!addr.firstName && !addr.lastName) continue;
        const addrName = buildDisplayName(addr.firstName, addr.lastName, null, '').toLowerCase().trim();
        if (addrName && addrName === userNameLower && normalizeEmail(addr.email) !== normalizeEmail(user.email)) {
          duplicates.push({
            personA: `user:${user.id}`,
            personB: `addr:${addr.id}`,
            matchType: 'name',
            confidence: 60,
          });
        }
      }
    }

    // Limit results for performance
    return duplicates.slice(0, 100);
  },

  // =========================================================================
  // n) GET HOUSEHOLD
  // =========================================================================
  async getHousehold(locationId: string) {
    const location = await prisma.location.findUnique({
      where: { id: locationId },
      select: { id: true, address: true },
    });
    if (!location) throw new AppError(404, 'Location not found', 'NOT_FOUND');

    const addresses = await prisma.address.findMany({
      where: { locationId },
      select: {
        id: true, unitNumber: true, firstName: true, lastName: true,
        email: true, phone: true, supportLevel: true, notes: true,
        contactAddresses: {
          include: {
            contact: {
              select: {
                id: true, displayName: true, email: true, phone: true,
                tags: true, supportLevel: true, notes: true,
              },
            },
          },
        },
      },
      orderBy: { unitNumber: 'asc' },
    });

    const household = addresses.map(addr => ({
      addressId: addr.id,
      unitNumber: addr.unitNumber,
      occupant: {
        displayName: buildDisplayName(addr.firstName, addr.lastName, addr.email, `Unit ${addr.unitNumber || 'Main'}`),
        firstName: addr.firstName,
        lastName: addr.lastName,
        email: addr.email,
        phone: addr.phone,
        supportLevel: addr.supportLevel,
        notes: addr.notes,
      },
      contact: addr.contactAddresses[0]?.contact || null,
    }));

    return { location, household };
  },

  // =========================================================================
  // o) DETECT HOUSEHOLD
  // =========================================================================
  async detectHousehold(locationId: string) {
    const location = await prisma.location.findUnique({ where: { id: locationId } });
    if (!location) throw new AppError(404, 'Location not found', 'NOT_FOUND');

    // Find all managed contacts at this location
    const contactAddresses = await prisma.contactAddress.findMany({
      where: {
        address: { locationId },
      },
      include: {
        contact: { select: { id: true, displayName: true, mergedIntoId: true } },
      },
    });

    const activeContacts = contactAddresses
      .filter(ca => !ca.contact.mergedIntoId)
      .map(ca => ca.contact);

    if (activeContacts.length < 2) {
      return { created: 0, message: 'Need at least 2 managed contacts at this location to detect household.' };
    }

    let created = 0;

    // Create HOUSEHOLD connections between all pairs
    for (let i = 0; i < activeContacts.length; i++) {
      for (let j = i + 1; j < activeContacts.length; j++) {
        const existing = await prisma.contactConnection.findUnique({
          where: {
            fromContactId_toContactId_type: {
              fromContactId: activeContacts[i].id,
              toContactId: activeContacts[j].id,
              type: 'HOUSEHOLD',
            },
          },
        });

        if (!existing) {
          // Also check reverse direction
          const existingReverse = await prisma.contactConnection.findUnique({
            where: {
              fromContactId_toContactId_type: {
                fromContactId: activeContacts[j].id,
                toContactId: activeContacts[i].id,
                type: 'HOUSEHOLD',
              },
            },
          });

          if (!existingReverse) {
            await prisma.contactConnection.create({
              data: {
                fromContactId: activeContacts[i].id,
                toContactId: activeContacts[j].id,
                type: 'HOUSEHOLD',
                label: `Household at ${location.address}`,
                isBidirectional: true,
              },
            });
            created++;
          }
        }
      }
    }

    return { created, message: `Created ${created} household connection(s) between ${activeContacts.length} contacts.` };
  },

  // =========================================================================
  // p) ACTIVITY TIMELINE
  // =========================================================================
  async getActivity(type: string, id: string, params: ActivityListInput) {
    const { page, limit, type: activityType } = params;

    // Resolve the person's identifiers
    let email: string | null = null;
    let phone: string | null = null;
    let userId: string | null = null;
    let contactId: string | null = null;

    if (type === 'user') {
      const user = await prisma.user.findUnique({ where: { id }, select: { id: true, email: true, phone: true, contact: true } });
      if (!user) throw new AppError(404, 'User not found', 'NOT_FOUND');
      email = user.email;
      phone = user.phone;
      userId = user.id;
      contactId = user.contact?.id || null;
    } else if (type === 'addr') {
      const addr = await prisma.address.findUnique({
        where: { id },
        select: { id: true, email: true, phone: true, contactAddresses: { select: { contactId: true } } },
      });
      if (!addr) throw new AppError(404, 'Address not found', 'NOT_FOUND');
      email = addr.email;
      phone = addr.phone;
      contactId = addr.contactAddresses[0]?.contactId || null;
    } else if (type === 'contact') {
      const contact = await prisma.contact.findUnique({ where: { id }, select: { id: true, email: true, phone: true, userId: true } });
      if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
      email = contact.email;
      phone = contact.phone;
      userId = contact.userId;
      contactId = contact.id;
    } else {
      throw new AppError(400, 'Invalid person type', 'INVALID_TYPE');
    }

    // Query activities from multiple sources in parallel
    const activities: ActivityItem[] = [];

    const queries: Promise<void>[] = [];

    // Campaign emails
    if (email && (!activityType || activityType === 'EMAIL_SENT')) {
      queries.push(
        prisma.campaignEmail.findMany({
          where: { userEmail: { equals: email, mode: 'insensitive' } },
          select: { id: true, subject: true, recipientName: true, recipientEmail: true, sentAt: true, campaignSlug: true },
          orderBy: { sentAt: 'desc' },
          take: 50,
        }).then(rows => {
          for (const r of rows) {
            activities.push({
              id: `email:${r.id}`,
              type: 'EMAIL_SENT',
              title: `Sent email: ${r.subject}`,
              description: `To ${r.recipientName || r.recipientEmail}`,
              occurredAt: r.sentAt.toISOString(),
              metadata: { campaignSlug: r.campaignSlug },
            });
          }
        })
      );
    }

    // Representative responses
    if (email && (!activityType || activityType === 'RESPONSE_SUBMITTED')) {
      queries.push(
        prisma.representativeResponse.findMany({
          where: { submittedByEmail: { equals: email, mode: 'insensitive' } },
          select: { id: true, representativeName: true, responseType: true, createdAt: true, campaignSlug: true },
          orderBy: { createdAt: 'desc' },
          take: 50,
        }).then(rows => {
          for (const r of rows) {
            activities.push({
              id: `response:${r.id}`,
              type: 'RESPONSE_SUBMITTED',
              title: `Response from ${r.representativeName}`,
              description: `Type: ${r.responseType}`,
              occurredAt: r.createdAt.toISOString(),
              metadata: { campaignSlug: r.campaignSlug },
            });
          }
        })
      );
    }

    // Shift signups
    if (email && (!activityType || activityType === 'SHIFT_SIGNUP')) {
      queries.push(
        prisma.shiftSignup.findMany({
          where: { userEmail: { equals: email, mode: 'insensitive' } },
          select: { id: true, shiftTitle: true, signupDate: true, status: true },
          orderBy: { signupDate: 'desc' },
          take: 50,
        }).then(rows => {
          for (const r of rows) {
            activities.push({
              id: `shift:${r.id}`,
              type: 'SHIFT_SIGNUP',
              title: `Signed up for shift: ${r.shiftTitle || 'Untitled'}`,
              description: `Status: ${r.status}`,
              occurredAt: r.signupDate.toISOString(),
            });
          }
        })
      );
    }

    // Canvass visits
    if (userId && (!activityType || activityType === 'CANVASS_VISIT')) {
      queries.push(
        prisma.canvassVisit.findMany({
          where: { userId },
          select: { id: true, outcome: true, visitedAt: true, notes: true, address: { select: { location: { select: { address: true } } } } },
          orderBy: { visitedAt: 'desc' },
          take: 50,
        }).then(rows => {
          for (const r of rows) {
            activities.push({
              id: `canvass:${r.id}`,
              type: 'CANVASS_VISIT',
              title: `Canvass visit: ${r.outcome}`,
              description: r.address?.location?.address || null,
              occurredAt: r.visitedAt.toISOString(),
            });
          }
        })
      );
    }

    // Orders/Donations
    if (email && (!activityType || activityType === 'DONATION' || activityType === 'PURCHASE')) {
      queries.push(
        prisma.order.findMany({
          where: { buyerEmail: { equals: email, mode: 'insensitive' } },
          select: { id: true, type: true, amountCAD: true, status: true, completedAt: true, createdAt: true, buyerName: true },
          orderBy: { createdAt: 'desc' },
          take: 50,
        }).then(rows => {
          for (const r of rows) {
            const isDonation = r.type === 'donation';
            const actType = isDonation ? 'DONATION' : 'PURCHASE';
            if (activityType && activityType !== actType) return;
            activities.push({
              id: `order:${r.id}`,
              type: actType,
              title: `${isDonation ? 'Donation' : 'Purchase'}: $${(r.amountCAD / 100).toFixed(2)}`,
              description: `Status: ${r.status}`,
              occurredAt: (r.completedAt || r.createdAt).toISOString(),
            });
          }
        })
      );
    }

    // SMS messages
    if (phone && (!activityType || activityType === 'SMS_SENT' || activityType === 'SMS_RECEIVED')) {
      queries.push(
        prisma.smsMessage.findMany({
          where: { phone: normalizePhone(phone)! },
          select: { id: true, direction: true, message: true, sentAt: true, status: true },
          orderBy: { sentAt: 'desc' },
          take: 50,
        }).then(rows => {
          for (const r of rows) {
            const smsType = r.direction === 'OUTBOUND' ? 'SMS_SENT' : 'SMS_RECEIVED';
            if (activityType && activityType !== smsType) return;
            activities.push({
              id: `sms:${r.id}`,
              type: smsType,
              title: `SMS ${r.direction === 'OUTBOUND' ? 'sent' : 'received'}`,
              description: r.message.length > 100 ? r.message.slice(0, 100) + '...' : r.message,
              occurredAt: r.sentAt.toISOString(),
            });
          }
        })
      );
    }

    // Video views
    if (userId && (!activityType || activityType === 'VIDEO_VIEW')) {
      queries.push(
        prisma.videoView.findMany({
          where: { userId },
          select: { id: true, watchTimeSeconds: true, completed: true, createdAt: true, video: { select: { title: true } } },
          orderBy: { createdAt: 'desc' },
          take: 50,
        }).then(rows => {
          for (const r of rows) {
            activities.push({
              id: `video:${r.id}`,
              type: 'VIDEO_VIEW',
              title: `Watched: ${r.video?.title || 'Unknown video'}`,
              description: `${r.watchTimeSeconds}s watched${r.completed ? ' (completed)' : ''}`,
              occurredAt: r.createdAt.toISOString(),
            });
          }
        })
      );
    }

    // ContactActivity records (notes, merges, etc.)
    if (contactId && (!activityType || activityType === 'NOTE_ADDED' || activityType === 'CONTACT_MERGED')) {
      queries.push(
        prisma.contactActivity.findMany({
          where: {
            contactId,
            ...(activityType ? { type: activityType } : {}),
          },
          select: { id: true, type: true, title: true, description: true, occurredAt: true, metadata: true },
          orderBy: { occurredAt: 'desc' },
          take: 50,
        }).then(rows => {
          for (const r of rows) {
            activities.push({
              id: `activity:${r.id}`,
              type: r.type,
              title: r.title,
              description: r.description,
              occurredAt: r.occurredAt.toISOString(),
              metadata: r.metadata,
            });
          }
        })
      );
    }

    await Promise.all(queries);

    // Sort by date DESC and paginate
    activities.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());

    const total = activities.length;
    const totalPages = Math.ceil(total / limit);
    const start = (page - 1) * limit;
    const paged = activities.slice(start, start + limit);

    return {
      activities: paged,
      pagination: { page, limit, total, totalPages },
    };
  },

  // =========================================================================
  // q-email) CONTACT EMAIL MANAGEMENT
  // =========================================================================

  /** Sync the denormalized Contact.email field with the primary ContactEmail */
  async syncPrimaryEmail(contactId: string, tx?: Prisma.TransactionClient) {
    const db = tx || prisma;
    const primary = await db.contactEmail.findFirst({
      where: { contactId, isPrimary: true },
    });
    await db.contact.update({
      where: { id: contactId },
      data: { email: primary?.email || null },
    });
  },

  /** Sync the denormalized Contact.phone field with the primary ContactPhone */
  async syncPrimaryPhone(contactId: string, tx?: Prisma.TransactionClient) {
    const db = tx || prisma;
    const primary = await db.contactPhone.findFirst({
      where: { contactId, isPrimary: true },
    });
    await db.contact.update({
      where: { id: contactId },
      data: { phone: primary?.phone || null },
    });
  },

  async getEmails(contactId: string) {
    const contact = await prisma.contact.findUnique({ where: { id: contactId } });
    if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
    if (contact.mergedIntoId) throw new AppError(400, 'Contact has been merged', 'MERGED_CONTACT');

    return prisma.contactEmail.findMany({
      where: { contactId },
      orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
    });
  },

  async addEmail(contactId: string, input: AddContactEmailInput) {
    const contact = await prisma.contact.findUnique({ where: { id: contactId } });
    if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
    if (contact.mergedIntoId) throw new AppError(400, 'Contact has been merged', 'MERGED_CONTACT');

    // Cap at 10
    const count = await prisma.contactEmail.count({ where: { contactId } });
    if (count >= 10) throw new AppError(400, 'Maximum of 10 emails per contact', 'LIMIT_EXCEEDED');

    const normalizedEmail = input.email.trim().toLowerCase();

    // If first email or explicitly primary, set as primary
    const shouldBePrimary = count === 0 || input.isPrimary === true;

    await prisma.$transaction(async (tx) => {
      if (shouldBePrimary) {
        await tx.contactEmail.updateMany({
          where: { contactId, isPrimary: true },
          data: { isPrimary: false },
        });
      }

      await tx.contactEmail.create({
        data: {
          contactId,
          email: normalizedEmail,
          label: input.label || null,
          isPrimary: shouldBePrimary,
        },
      });

      if (shouldBePrimary) {
        await this.syncPrimaryEmail(contactId, tx);
      }
    });

    return prisma.contactEmail.findMany({
      where: { contactId },
      orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
    });
  },

  async removeEmail(contactId: string, emailId: string) {
    const record = await prisma.contactEmail.findUnique({ where: { id: emailId } });
    if (!record) throw new AppError(404, 'Email record not found', 'NOT_FOUND');
    if (record.contactId !== contactId) throw new AppError(403, 'Email does not belong to this contact', 'FORBIDDEN');

    const wasPrimary = record.isPrimary;

    await prisma.$transaction(async (tx) => {
      await tx.contactEmail.delete({ where: { id: emailId } });

      // Auto-promote oldest remaining if was primary
      if (wasPrimary) {
        const oldest = await tx.contactEmail.findFirst({
          where: { contactId },
          orderBy: { createdAt: 'asc' },
        });
        if (oldest) {
          await tx.contactEmail.update({
            where: { id: oldest.id },
            data: { isPrimary: true },
          });
        }
        await this.syncPrimaryEmail(contactId, tx);
      }
    });
  },

  async setPrimaryEmail(contactId: string, emailId: string) {
    const record = await prisma.contactEmail.findUnique({ where: { id: emailId } });
    if (!record) throw new AppError(404, 'Email record not found', 'NOT_FOUND');
    if (record.contactId !== contactId) throw new AppError(403, 'Email does not belong to this contact', 'FORBIDDEN');

    await prisma.$transaction(async (tx) => {
      await tx.contactEmail.updateMany({
        where: { contactId, isPrimary: true },
        data: { isPrimary: false },
      });
      await tx.contactEmail.update({
        where: { id: emailId },
        data: { isPrimary: true },
      });
      await this.syncPrimaryEmail(contactId, tx);
    });
  },

  // =========================================================================
  // q-phone) CONTACT PHONE MANAGEMENT
  // =========================================================================

  async getPhones(contactId: string) {
    const contact = await prisma.contact.findUnique({ where: { id: contactId } });
    if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
    if (contact.mergedIntoId) throw new AppError(400, 'Contact has been merged', 'MERGED_CONTACT');

    return prisma.contactPhone.findMany({
      where: { contactId },
      orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
    });
  },

  async addPhone(contactId: string, input: AddContactPhoneInput) {
    const contact = await prisma.contact.findUnique({ where: { id: contactId } });
    if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
    if (contact.mergedIntoId) throw new AppError(400, 'Contact has been merged', 'MERGED_CONTACT');

    const count = await prisma.contactPhone.count({ where: { contactId } });
    if (count >= 10) throw new AppError(400, 'Maximum of 10 phones per contact', 'LIMIT_EXCEEDED');

    const normalizedPhone = input.phone.replace(/[^0-9+]/g, '') || input.phone.trim();
    const shouldBePrimary = count === 0 || input.isPrimary === true;

    await prisma.$transaction(async (tx) => {
      if (shouldBePrimary) {
        await tx.contactPhone.updateMany({
          where: { contactId, isPrimary: true },
          data: { isPrimary: false },
        });
      }

      await tx.contactPhone.create({
        data: {
          contactId,
          phone: normalizedPhone,
          label: input.label || null,
          isPrimary: shouldBePrimary,
        },
      });

      if (shouldBePrimary) {
        await this.syncPrimaryPhone(contactId, tx);
      }
    });

    return prisma.contactPhone.findMany({
      where: { contactId },
      orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
    });
  },

  async removePhone(contactId: string, phoneId: string) {
    const record = await prisma.contactPhone.findUnique({ where: { id: phoneId } });
    if (!record) throw new AppError(404, 'Phone record not found', 'NOT_FOUND');
    if (record.contactId !== contactId) throw new AppError(403, 'Phone does not belong to this contact', 'FORBIDDEN');

    const wasPrimary = record.isPrimary;

    await prisma.$transaction(async (tx) => {
      await tx.contactPhone.delete({ where: { id: phoneId } });

      if (wasPrimary) {
        const oldest = await tx.contactPhone.findFirst({
          where: { contactId },
          orderBy: { createdAt: 'asc' },
        });
        if (oldest) {
          await tx.contactPhone.update({
            where: { id: oldest.id },
            data: { isPrimary: true },
          });
        }
        await this.syncPrimaryPhone(contactId, tx);
      }
    });
  },

  async setPrimaryPhone(contactId: string, phoneId: string) {
    const record = await prisma.contactPhone.findUnique({ where: { id: phoneId } });
    if (!record) throw new AppError(404, 'Phone record not found', 'NOT_FOUND');
    if (record.contactId !== contactId) throw new AppError(403, 'Phone does not belong to this contact', 'FORBIDDEN');

    await prisma.$transaction(async (tx) => {
      await tx.contactPhone.updateMany({
        where: { contactId, isPrimary: true },
        data: { isPrimary: false },
      });
      await tx.contactPhone.update({
        where: { id: phoneId },
        data: { isPrimary: true },
      });
      await this.syncPrimaryPhone(contactId, tx);
    });
  },

  // =========================================================================
  // q-addr) CONTACT ADDRESS MANAGEMENT
  // =========================================================================

  async getAddresses(contactId: string) {
    const contact = await prisma.contact.findUnique({ where: { id: contactId } });
    if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
    if (contact.mergedIntoId) throw new AppError(400, 'Contact has been merged', 'MERGED_CONTACT');

    const contactAddresses = await prisma.contactAddress.findMany({
      where: { contactId },
      include: {
        address: {
          include: {
            location: {
              select: {
                id: true,
                address: true,
                latitude: true,
                longitude: true,
                postalCode: true,
                province: true,
                geocodeConfidence: true,
              },
            },
          },
        },
      },
      orderBy: { createdAt: 'asc' },
    });

    return contactAddresses;
  },

  async addAddress(contactId: string, input: AddContactAddressInput, userId: string | null) {
    const contact = await prisma.contact.findUnique({ where: { id: contactId } });
    if (!contact) throw new AppError(404, 'Contact not found', 'NOT_FOUND');
    if (contact.mergedIntoId) throw new AppError(400, 'Contact has been merged', 'MERGED_CONTACT');

    // Check autoSyncPeopleToMap setting
    const settings = await prisma.siteSettings.findFirst();
    const shouldAddToMap = input.addToMap || settings?.autoSyncPeopleToMap || false;

    let addressId: string;

    if (shouldAddToMap) {
      // Use locationsService.create() to create Location + Address with auto-geocoding
      const { locationsService } = await import('../map/locations/locations.service');
      // Don't copy contact's personal info (name/email/phone) into the Address
      // occupant fields — that would cause the address to appear as a separate
      // "ADDRESS_OCCUPANT" person in listPeople, overriding the contact's
      // original source. The ContactAddress junction table already links them.
      const location = await locationsService.create(
        {
          address: input.address,
          unitNumber: input.unitNumber,
          sign: false,
        },
        userId,
      );

      // Get the newly created Address from this Location
      const newAddress = await prisma.address.findFirst({
        where: { locationId: location.id },
        orderBy: { createdAt: 'desc' },
      });

      if (!newAddress) {
        throw new AppError(500, 'Failed to create address record', 'ADDRESS_CREATE_FAILED');
      }
      addressId = newAddress.id;
    } else {
      // Create standalone Location (just address string + geocode) and Address
      const { geocodingService } = await import('../map/geocoding/geocoding.service');
      const geocodeResult = await geocodingService.geocode(input.address);

      const location = await prisma.location.create({
        data: {
          address: input.address,
          latitude: geocodeResult ? (geocodeResult.latitude as unknown as Prisma.Decimal) : (0 as unknown as Prisma.Decimal),
          longitude: geocodeResult ? (geocodeResult.longitude as unknown as Prisma.Decimal) : (0 as unknown as Prisma.Decimal),
          geocodeConfidence: geocodeResult?.confidence || null,
          geocodeProvider: geocodeResult?.provider || null,
          createdByUserId: userId,
        },
      });

      const address = await prisma.address.create({
        data: {
          locationId: location.id,
          unitNumber: input.unitNumber || null,
          createdByUserId: userId,
        },
      });

      addressId = address.id;
    }

    // If setting as primary, unset other primaries first
    if (input.isPrimary) {
      await prisma.contactAddress.updateMany({
        where: { contactId, isPrimary: true },
        data: { isPrimary: false },
      });
    }

    // Create the ContactAddress junction record
    const contactAddress = await prisma.contactAddress.create({
      data: {
        contactId,
        addressId,
        isPrimary: input.isPrimary,
      },
      include: {
        address: {
          include: {
            location: {
              select: {
                id: true,
                address: true,
                latitude: true,
                longitude: true,
                postalCode: true,
                province: true,
                geocodeConfidence: true,
              },
            },
          },
        },
      },
    });

    // Log activity
    await prisma.contactActivity.create({
      data: {
        contactId,
        type: 'NOTE_ADDED',
        title: 'Address added',
        description: `Address added: ${input.address}${input.unitNumber ? ` Unit ${input.unitNumber}` : ''}`,
      },
    });

    return contactAddress;
  },

  async removeAddress(contactId: string, contactAddressId: string) {
    const contactAddress = await prisma.contactAddress.findUnique({
      where: { id: contactAddressId },
      include: {
        address: {
          include: { location: { select: { address: true } } },
        },
      },
    });

    if (!contactAddress) throw new AppError(404, 'Contact address link not found', 'NOT_FOUND');
    if (contactAddress.contactId !== contactId) {
      throw new AppError(403, 'Contact address does not belong to this contact', 'FORBIDDEN');
    }

    const addressStr = contactAddress.address?.location?.address || 'Unknown';

    // Delete only the junction record — Location/Address stay on the map
    await prisma.contactAddress.delete({ where: { id: contactAddressId } });

    // Log activity
    await prisma.contactActivity.create({
      data: {
        contactId,
        type: 'NOTE_ADDED',
        title: 'Address unlinked',
        description: `Address unlinked: ${addressStr}`,
      },
    });
  },
};
