import crypto from 'crypto';
import { Prisma, PollVoteValue } from '@prisma/client';
import { prisma } from '../../config/database';
import { AppError } from '../../middleware/error-handler';
import { emailService } from '../../services/email.service';
import { generateSlug } from '../../utils/slug';
import { logger } from '../../utils/logger';
import { pollAutoFinalizeQueueService } from '../../services/poll-auto-finalize-queue.service';
import { participantNeedsService } from '../people/participant-needs.service';
import type { UpsertNeedsInput } from '../people/participant-needs.schemas';
import type {
  CreatePollInput,
  UpdatePollInput,
  AddOptionsInput,
  UpdateOptionInput,
  SubmitVotesInput,
  SubmitCommentInput,
  FinalizePollInput,
  ConvertToShiftInput,
  ListPollsInput,
} from './meeting-planner.schemas';

const pollInclude = {
  options: { orderBy: { sortOrder: 'asc' as const } },
  createdBy: { select: { id: true, name: true, email: true } },
  _count: { select: { options: true, votes: true, comments: true } },
} as const;

// Admin detail include — returns all vote fields (for admin endpoints)
const pollDetailInclude = {
  options: {
    orderBy: { sortOrder: 'asc' as const },
    include: {
      votes: { orderBy: { createdAt: 'asc' as const } },
    },
  },
  comments: { orderBy: { createdAt: 'asc' as const } },
  createdBy: { select: { id: true, name: true, email: true } },
  _count: { select: { options: true, votes: true, comments: true } },
} as const;

// Public detail include — strips voterEmail and voterToken from votes
const pollDetailPublicInclude = {
  options: {
    orderBy: { sortOrder: 'asc' as const },
    include: {
      votes: {
        orderBy: { createdAt: 'asc' as const },
        select: {
          id: true,
          pollId: true,
          optionId: true,
          voterName: true,
          userId: true,
          value: true,
          createdAt: true,
          // voterEmail and voterToken intentionally excluded
        },
      },
    },
  },
  comments: { orderBy: { createdAt: 'asc' as const } },
  createdBy: { select: { id: true, name: true } }, // exclude email from public
  _count: { select: { options: true, votes: true, comments: true } },
} as const;

function aggregateVotes<T extends { id: string; votes: Array<{ value: PollVoteValue }> }>(options: T[]) {
  return options.map((opt) => {
    let yesCount = 0;
    let ifNeedBeCount = 0;
    let noCount = 0;
    for (const v of opt.votes) {
      if (v.value === 'YES') yesCount++;
      else if (v.value === 'IF_NEED_BE') ifNeedBeCount++;
      else noCount++;
    }
    return {
      ...opt,
      yesCount,
      ifNeedBeCount,
      noCount,
      score: yesCount * 2 + ifNeedBeCount,
    };
  });
}

function groupVotesByVoter(votes: Array<{
  voterName: string;
  voterToken?: string | null;
  userId: string | null;
  optionId: string;
  value: PollVoteValue;
}>) {
  const voterMap = new Map<string, { name: string; voterKey: string; votes: Record<string, PollVoteValue> }>();
  for (const vote of votes) {
    const key = vote.userId || vote.voterToken || vote.voterName;
    if (!voterMap.has(key)) {
      voterMap.set(key, { name: vote.voterName, voterKey: key, votes: {} });
    }
    voterMap.get(key)!.votes[vote.optionId] = vote.value;
  }
  return Array.from(voterMap.values());
}

export const meetingPlannerService = {
  async findAll(filters: ListPollsInput) {
    const { page, limit, search, status } = filters;
    const where: Prisma.SchedulingPollWhereInput = {};

    if (status) where.status = status;
    if (search) {
      where.OR = [
        { title: { contains: search, mode: 'insensitive' } },
        { description: { contains: search, mode: 'insensitive' } },
      ];
    }

    const [polls, total] = await Promise.all([
      prisma.schedulingPoll.findMany({
        where,
        include: pollInclude,
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * limit,
        take: limit,
      }),
      prisma.schedulingPoll.count({ where }),
    ]);

    return {
      polls,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    };
  },

  async findById(id: string) {
    const poll = await prisma.schedulingPoll.findUnique({
      where: { id },
      include: pollDetailInclude,
    });
    if (!poll) throw new AppError(404, 'Poll not found');

    const optionsWithCounts = aggregateVotes(poll.options);
    const allVotes = poll.options.flatMap((opt) =>
      opt.votes.map((v) => ({ ...v, optionId: opt.id }))
    );
    const voters = groupVotesByVoter(allVotes);

    return { ...poll, options: optionsWithCounts, voters };
  },

  async findBySlug(slug: string) {
    const poll = await prisma.schedulingPoll.findUnique({
      where: { slug },
      include: pollDetailInclude,
    });
    if (!poll) throw new AppError(404, 'Poll not found');

    const optionsWithCounts = aggregateVotes(poll.options);
    const allVotes = poll.options.flatMap((opt) =>
      opt.votes.map((v) => ({ ...v, optionId: opt.id }))
    );
    const voters = groupVotesByVoter(allVotes);

    return { ...poll, options: optionsWithCounts, voters };
  },

  async findBySlugPublic(slug: string, userId?: string) {
    const poll = await prisma.schedulingPoll.findUnique({
      where: { slug },
      include: pollDetailPublicInclude,
    });
    if (!poll) throw new AppError(404, 'Poll not found');

    // If private and not authenticated, return limited data
    if (poll.isPrivate && !userId) {
      return {
        id: poll.id,
        slug: poll.slug,
        title: poll.title,
        description: poll.description,
        location: poll.location,
        status: poll.status,
        timezone: poll.timezone,
        allowAnonymous: poll.allowAnonymous,
        isPrivate: poll.isPrivate,
        notifyOnVote: poll.notifyOnVote,
        createdBy: poll.createdBy,
        createdByUserId: poll.createdByUserId,
        createdAt: poll.createdAt,
        updatedAt: poll.updatedAt,
        votingDeadline: poll.votingDeadline,
        finalizedOptionId: null,
        finalizedOption: null,
        convertedShiftId: null,
        convertedGancioEventId: null,
        requiresAuth: true,
        options: [],
        voters: [],
        comments: [],
        _count: { options: 0, votes: 0, comments: 0 },
      };
    }

    const optionsWithCounts = aggregateVotes(poll.options);
    const allVotes = poll.options.flatMap((opt) =>
      opt.votes.map((v) => ({ ...v, optionId: opt.id }))
    );
    const voters = groupVotesByVoter(allVotes);

    return { ...poll, options: optionsWithCounts, voters, requiresAuth: false };
  },

  async findAllPublic(filters: ListPollsInput) {
    const result = await this.findAll({ ...filters, status: 'OPEN' });
    return {
      ...result,
      // Filter out private polls entirely from the public listing
      polls: result.polls
        .filter((poll) => !poll.isPrivate)
        .map((poll) => ({
          ...poll,
          requiresAuth: false,
          // Strip organizer email from public listing
          createdBy: poll.createdBy ? { id: poll.createdBy.id, name: poll.createdBy.name } : null,
        })),
    };
  },

  async create(data: CreatePollInput, userId: string) {
    const slug = generateSlug(data.title);

    const poll = await prisma.schedulingPoll.create({
      data: {
        slug,
        title: data.title,
        description: data.description,
        location: data.location,
        timezone: data.timezone,
        allowAnonymous: data.allowAnonymous,
        isPrivate: data.isPrivate,
        notifyOnVote: data.notifyOnVote,
        votingDeadline: data.votingDeadline ? new Date(data.votingDeadline) : null,
        autoFinalize: data.autoFinalize,
        autoFinalizeThreshold: data.autoFinalizeThreshold,
        autoConvertToCalendar: data.autoConvertToCalendar,
        autoConvertToGancio: data.autoConvertToGancio,
        autoConvertToShift: data.autoConvertToShift,
        tieBreaker: data.tieBreaker,
        autoEnrollVoters: data.autoEnrollVoters,
        createdByUserId: userId,
        options: {
          create: data.options.map((opt, i) => ({
            date: new Date(opt.date),
            startTime: opt.startTime,
            endTime: opt.endTime,
            sortOrder: i,
          })),
        },
      },
      include: pollInclude,
    });

    // Schedule auto-finalize job if enabled with a deadline
    if (poll.autoFinalize && poll.votingDeadline) {
      const jobId = await pollAutoFinalizeQueueService.scheduleJob(poll.id, poll.votingDeadline);
      if (jobId) {
        await prisma.schedulingPoll.update({
          where: { id: poll.id },
          data: { autoFinalizeJobId: jobId },
        });
      }
    }

    return poll;
  },

  async update(id: string, data: UpdatePollInput) {
    const existing = await prisma.schedulingPoll.findUnique({ where: { id } });
    if (!existing) throw new AppError(404, 'Poll not found');

    const updateData: Prisma.SchedulingPollUncheckedUpdateInput = {};
    if (data.title !== undefined) updateData.title = data.title;
    if (data.description !== undefined) updateData.description = data.description;
    if (data.location !== undefined) updateData.location = data.location;
    if (data.timezone !== undefined) updateData.timezone = data.timezone;
    if (data.allowAnonymous !== undefined) updateData.allowAnonymous = data.allowAnonymous;
    if (data.isPrivate !== undefined) updateData.isPrivate = data.isPrivate;
    if (data.notifyOnVote !== undefined) updateData.notifyOnVote = data.notifyOnVote;
    if (data.votingDeadline !== undefined) {
      updateData.votingDeadline = data.votingDeadline ? new Date(data.votingDeadline) : null;
    }
    if (data.status !== undefined) updateData.status = data.status;
    if (data.autoFinalize !== undefined) updateData.autoFinalize = data.autoFinalize;
    if (data.autoFinalizeThreshold !== undefined) updateData.autoFinalizeThreshold = data.autoFinalizeThreshold;
    if (data.autoConvertToCalendar !== undefined) updateData.autoConvertToCalendar = data.autoConvertToCalendar;
    if (data.autoConvertToGancio !== undefined) updateData.autoConvertToGancio = data.autoConvertToGancio;
    if (data.autoConvertToShift !== undefined) updateData.autoConvertToShift = data.autoConvertToShift;
    if (data.tieBreaker !== undefined) updateData.tieBreaker = data.tieBreaker;
    if (data.autoEnrollVoters !== undefined) updateData.autoEnrollVoters = data.autoEnrollVoters;

    // Cancel existing job if status changed to CANCELLED or autoFinalize disabled
    if (data.status === 'CANCELLED' || data.autoFinalize === false) {
      await pollAutoFinalizeQueueService.cancelJob(id);
      updateData.autoFinalizeJobId = null;
    }

    const updated = await prisma.schedulingPoll.update({
      where: { id },
      data: updateData,
      include: pollInclude,
    });

    // Re-schedule auto-finalize job if deadline or autoFinalize changed
    const deadlineChanged = data.votingDeadline !== undefined;
    const autoFinalizeChanged = data.autoFinalize !== undefined;
    if ((deadlineChanged || autoFinalizeChanged) && updated.autoFinalize && updated.votingDeadline && updated.status === 'OPEN') {
      await pollAutoFinalizeQueueService.cancelJob(id);
      const jobId = await pollAutoFinalizeQueueService.scheduleJob(id, updated.votingDeadline);
      if (jobId) {
        await prisma.schedulingPoll.update({
          where: { id },
          data: { autoFinalizeJobId: jobId },
        });
      }
    }

    return updated;
  },

  async delete(id: string) {
    const existing = await prisma.schedulingPoll.findUnique({ where: { id } });
    if (!existing) throw new AppError(404, 'Poll not found');
    await prisma.schedulingPoll.delete({ where: { id } });
  },

  async addOptions(pollId: string, data: AddOptionsInput) {
    const poll = await prisma.schedulingPoll.findUnique({
      where: { id: pollId },
      include: { options: true },
    });
    if (!poll) throw new AppError(404, 'Poll not found');
    if (poll.status !== 'OPEN') throw new AppError(400, 'Cannot add options to a non-open poll');

    const maxSort = poll.options.reduce((max, o) => Math.max(max, o.sortOrder), -1);

    await prisma.schedulingPollOption.createMany({
      data: data.options.map((opt, i) => ({
        pollId,
        date: new Date(opt.date),
        startTime: opt.startTime,
        endTime: opt.endTime,
        sortOrder: maxSort + 1 + i,
      })),
    });

    return this.findById(pollId);
  },

  async updateOption(pollId: string, optionId: string, data: UpdateOptionInput) {
    const poll = await prisma.schedulingPoll.findUnique({ where: { id: pollId } });
    if (!poll) throw new AppError(404, 'Poll not found');
    if (poll.status !== 'OPEN') throw new AppError(400, 'Cannot update options on a non-open poll');

    const option = await prisma.schedulingPollOption.findFirst({
      where: { id: optionId, pollId },
    });
    if (!option) throw new AppError(404, 'Option not found');

    const updateData: Prisma.SchedulingPollOptionUncheckedUpdateInput = {};
    if (data.date !== undefined) updateData.date = new Date(data.date);
    if (data.startTime !== undefined) updateData.startTime = data.startTime;
    if (data.endTime !== undefined) updateData.endTime = data.endTime;

    await prisma.schedulingPollOption.update({
      where: { id: optionId },
      data: updateData,
    });

    return this.findById(pollId);
  },

  async removeOption(pollId: string, optionId: string) {
    const option = await prisma.schedulingPollOption.findFirst({
      where: { id: optionId, pollId },
    });
    if (!option) throw new AppError(404, 'Option not found');

    await prisma.schedulingPollOption.delete({ where: { id: optionId } });
    return this.findById(pollId);
  },

  async submitVotes(slug: string, data: SubmitVotesInput, userId?: string) {
    const poll = await prisma.schedulingPoll.findUnique({
      where: { slug },
      include: { options: true, createdBy: { select: { email: true, name: true } } },
    });
    if (!poll) throw new AppError(404, 'Poll not found');
    if (poll.status !== 'OPEN') throw new AppError(400, 'This poll is no longer accepting votes');
    if (poll.votingDeadline && new Date() > poll.votingDeadline) {
      throw new AppError(400, 'The voting deadline has passed');
    }
    if (poll.isPrivate && !userId) {
      throw new AppError(401, 'This poll requires authentication to vote');
    }
    if (!poll.allowAnonymous && !userId) {
      throw new AppError(401, 'This poll requires authentication to vote');
    }

    // Validate all optionIds belong to this poll
    const optionIds = new Set(poll.options.map((o) => o.id));
    for (const vote of data.votes) {
      if (!optionIds.has(vote.optionId)) {
        throw new AppError(400, `Invalid option ID: ${vote.optionId}`);
      }
    }

    // Generate token for anonymous voters (or reuse existing)
    const voterToken = userId ? null : (data.voterToken || generateVoterToken());

    // Upsert votes in a transaction
    await prisma.$transaction(
      data.votes.map((vote) => {
        if (userId) {
          return prisma.schedulingPollVote.upsert({
            where: { optionId_userId: { optionId: vote.optionId, userId } },
            create: {
              pollId: poll.id,
              optionId: vote.optionId,
              userId,
              voterName: data.voterName,
              voterEmail: data.voterEmail,
              value: vote.value,
            },
            update: {
              voterName: data.voterName,
              voterEmail: data.voterEmail,
              value: vote.value,
            },
          });
        } else {
          return prisma.schedulingPollVote.upsert({
            where: { optionId_voterToken: { optionId: vote.optionId, voterToken: voterToken! } },
            create: {
              pollId: poll.id,
              optionId: vote.optionId,
              voterName: data.voterName,
              voterEmail: data.voterEmail,
              voterToken,
              value: vote.value,
            },
            update: {
              voterName: data.voterName,
              voterEmail: data.voterEmail,
              value: vote.value,
            },
          });
        }
      })
    );

    // Link voter to Contact CRM + upsert participant needs (fire-and-forget)
    if (data.voterEmail) {
      this.linkVoterToContact(poll.id, poll.title, data.voterName, data.voterEmail, data.votes.map(v => v.optionId), userId ?? undefined, voterToken ?? undefined, data.participantNeeds).catch((err) =>
        logger.error('Failed to link voter to contact', { error: err })
      );
    } else if (userId && data.participantNeeds) {
      // Authenticated voter without email — upsert needs by userId
      participantNeedsService.upsert(data.participantNeeds, userId).catch((err) =>
        logger.error('Failed to upsert participant needs', { error: err })
      );
    }

    // Notify organizer
    if (poll.notifyOnVote) {
      this.notifyOrganizer(poll.createdBy.email, poll.title, data.voterName).catch((err) =>
        logger.error('Failed to send vote notification', { error: err })
      );
    }

    // Check auto-finalize threshold
    if (poll.autoFinalize && poll.autoFinalizeThreshold && poll.status === 'OPEN') {
      const optionsWithVotes = await prisma.schedulingPollOption.findMany({
        where: { pollId: poll.id },
        include: { votes: { where: { value: 'YES' } } },
      });
      for (const opt of optionsWithVotes) {
        if (opt.votes.length >= poll.autoFinalizeThreshold) {
          // Fire-and-forget auto-finalization
          this.autoFinalizeAndConvert(poll.id, opt.id).catch((err) =>
            logger.error('Auto-finalize by threshold failed', { error: err, pollId: poll.id })
          );
          break;
        }
      }
    }

    return { voterToken };
  },

  async addComment(slug: string, data: SubmitCommentInput, userId?: string) {
    const poll = await prisma.schedulingPoll.findUnique({ where: { slug } });
    if (!poll) throw new AppError(404, 'Poll not found');
    if (poll.isPrivate && !userId) {
      throw new AppError(401, 'This poll requires authentication to comment');
    }
    if (!poll.allowAnonymous && !userId) {
      throw new AppError(401, 'This poll requires authentication to comment');
    }

    return prisma.schedulingPollComment.create({
      data: {
        pollId: poll.id,
        userId,
        authorName: data.authorName,
        content: data.content,
      },
    });
  },

  async removeVoter(pollId: string, voterKey: string) {
    const poll = await prisma.schedulingPoll.findUnique({ where: { id: pollId } });
    if (!poll) throw new AppError(404, 'Poll not found');

    const result = await prisma.schedulingPollVote.deleteMany({
      where: {
        pollId,
        OR: [
          { userId: voterKey },
          { voterToken: voterKey },
          { voterName: voterKey, userId: null, voterToken: null },
        ],
      },
    });

    if (result.count === 0) throw new AppError(404, 'Voter not found');
    return this.findById(pollId);
  },

  async deleteComment(pollId: string, commentId: string) {
    const comment = await prisma.schedulingPollComment.findFirst({
      where: { id: commentId, pollId },
    });
    if (!comment) throw new AppError(404, 'Comment not found');
    await prisma.schedulingPollComment.delete({ where: { id: commentId } });
  },

  async finalize(id: string, data: FinalizePollInput) {
    const poll = await prisma.schedulingPoll.findUnique({
      where: { id },
      include: { options: true },
    });
    if (!poll) throw new AppError(404, 'Poll not found');
    if (poll.status === 'FINALIZED') throw new AppError(400, 'Poll is already finalized');

    const option = poll.options.find((o) => o.id === data.optionId);
    if (!option) throw new AppError(400, 'Option not found in this poll');

    // Cancel any pending auto-finalize job
    await pollAutoFinalizeQueueService.cancelJob(id);

    const updated = await prisma.schedulingPoll.update({
      where: { id },
      data: {
        status: 'FINALIZED',
        finalizedOptionId: data.optionId,
        autoFinalizeJobId: null,
      },
      include: pollDetailInclude,
    });

    // Notify all voters with emails
    this.notifyVotersFinalized(updated).catch((err) =>
      logger.error('Failed to send finalization notifications', { error: err })
    );

    return updated;
  },

  async convertToShift(id: string, data: ConvertToShiftInput) {
    const poll = await prisma.schedulingPoll.findUnique({
      where: { id },
      include: { options: true },
    });
    if (!poll) throw new AppError(404, 'Poll not found');
    if (poll.status !== 'FINALIZED') throw new AppError(400, 'Poll must be finalized before converting');
    if (poll.convertedShiftId) throw new AppError(400, 'Poll has already been converted to a shift');
    if (!poll.finalizedOptionId) throw new AppError(400, 'No finalized option selected');

    const option = poll.options.find((o) => o.id === poll.finalizedOptionId);
    if (!option) throw new AppError(400, 'Finalized option not found');

    const [shift] = await prisma.$transaction([
      prisma.shift.create({
        data: {
          title: poll.title,
          description: poll.description,
          date: option.date,
          startTime: option.startTime,
          endTime: option.endTime,
          location: poll.location,
          maxVolunteers: data.maxVolunteers,
          isPublic: data.isPublic,
          cutId: data.cutId,
        },
      }),
    ]);

    await prisma.schedulingPoll.update({
      where: { id },
      data: { convertedShiftId: shift.id },
    });

    return shift;
  },

  async convertToEvent(id: string) {
    const poll = await prisma.schedulingPoll.findUnique({
      where: { id },
      include: { options: true },
    });
    if (!poll) throw new AppError(404, 'Poll not found');
    if (poll.status !== 'FINALIZED') throw new AppError(400, 'Poll must be finalized before converting');
    if (poll.convertedGancioEventId) throw new AppError(400, 'Poll has already been converted to an event');
    if (!poll.finalizedOptionId) throw new AppError(400, 'No finalized option selected');

    const option = poll.options.find((o) => o.id === poll.finalizedOptionId);
    if (!option) throw new AppError(400, 'Finalized option not found');

    // Dynamically import gancio client to avoid hard dependency
    const { gancioClient } = await import('../../services/gancio.client');
    const eventId = await gancioClient.createEvent({
      title: poll.title,
      description: poll.description,
      location: poll.location,
      date: option.date,
      startTime: option.startTime,
      endTime: option.endTime,
    });

    if (!eventId) throw new AppError(500, 'Failed to create Gancio event');

    await prisma.schedulingPoll.update({
      where: { id },
      data: { convertedGancioEventId: eventId },
    });

    return { gancioEventId: eventId };
  },

  /**
   * Called by BullMQ worker when deadline fires. Picks the winning option and auto-finalizes.
   */
  async processAutoFinalize(pollId: string) {
    const poll = await prisma.schedulingPoll.findUnique({
      where: { id: pollId },
      include: {
        options: {
          orderBy: { date: 'asc' },
          include: { votes: true },
        },
        createdBy: { select: { email: true, name: true } },
      },
    });

    if (!poll || poll.status !== 'OPEN') {
      logger.info(`Poll ${pollId} skipped auto-finalize (status: ${poll?.status ?? 'not found'})`);
      return;
    }

    const scored = aggregateVotes(poll.options);
    const maxScore = Math.max(...scored.map((o) => o.score));

    // No viable option
    if (maxScore === 0) {
      await prisma.schedulingPoll.update({
        where: { id: pollId },
        data: { status: 'CLOSED', autoFinalizeJobId: null },
      });
      logger.info(`Poll ${pollId} closed with no viable votes`);
      // Notify organizer
      await emailService.sendEmail({
        to: poll.createdBy.email,
        subject: `Poll expired: "${poll.title}"`,
        html: `<p>Your scheduling poll "<strong>${escapeHtml(poll.title)}</strong>" has expired with no viable date options.</p>`,
        text: `Your scheduling poll "${poll.title}" has expired with no viable date options.`,
      }).catch((err) => logger.error('Failed to send poll expiry email', { error: err }));
      return;
    }

    // Find winners (could be tied)
    const winners = scored.filter((o) => o.score === maxScore);

    if (winners.length === 1) {
      await this.autoFinalizeAndConvert(pollId, winners[0].id);
      return;
    }

    // Tie-breaking
    if (poll.tieBreaker === 'earliest') {
      // Pick the earliest date+time
      const earliest = winners.sort((a, b) => {
        const dateA = new Date(a.date).getTime();
        const dateB = new Date(b.date).getTime();
        if (dateA !== dateB) return dateA - dateB;
        return a.startTime.localeCompare(b.startTime);
      })[0];
      await this.autoFinalizeAndConvert(pollId, earliest.id);
    } else {
      // organizer_choice — close and notify
      await prisma.schedulingPoll.update({
        where: { id: pollId },
        data: { status: 'CLOSED', autoFinalizeJobId: null },
      });
      await emailService.sendEmail({
        to: poll.createdBy.email,
        subject: `Tie in poll: "${poll.title}" — choose a winner`,
        html: `<p>Your scheduling poll "<strong>${escapeHtml(poll.title)}</strong>" has a tie between ${winners.length} options. Please finalize manually.</p>`,
        text: `Your scheduling poll "${poll.title}" has a tie between ${winners.length} options. Please finalize manually.`,
      }).catch((err) => logger.error('Failed to send tie notification', { error: err }));
    }
  },

  /**
   * Shared auto-finalize logic used by both deadline and threshold triggers.
   * Uses status guard to prevent double-finalize from concurrent invocations.
   */
  async autoFinalizeAndConvert(pollId: string, winningOptionId: string) {
    // Atomic guard: only finalize if still OPEN
    const updated = await prisma.schedulingPoll.updateMany({
      where: { id: pollId, status: 'OPEN' },
      data: {
        status: 'FINALIZED',
        finalizedOptionId: winningOptionId,
        autoFinalizeJobId: null,
      },
    });

    if (updated.count === 0) {
      logger.info(`Poll ${pollId} already finalized (concurrent guard)`);
      return;
    }

    // Cancel any pending deadline job
    await pollAutoFinalizeQueueService.cancelJob(pollId);

    // Re-fetch with full details for notifications and conversions
    const poll = await prisma.schedulingPoll.findUnique({
      where: { id: pollId },
      include: {
        ...pollDetailInclude,
      },
    });
    if (!poll) return;

    logger.info(`Auto-finalized poll ${pollId} with option ${winningOptionId}`);

    // Notify voters
    this.notifyVotersFinalized(poll).catch((err) =>
      logger.error('Failed to send auto-finalization notifications', { error: err })
    );

    const finalOption = poll.options.find((o: any) => o.id === winningOptionId);
    if (!finalOption) return;

    // Auto-convert to CalendarItem
    if (poll.autoConvertToCalendar) {
      try {
        const { calendarService } = await import('../calendar/calendar.service');
        const { CalendarSystemType } = await import('@prisma/client');
        await calendarService.ensureSystemLayers(poll.createdByUserId);
        const layers = await calendarService.getUserLayers(poll.createdByUserId);
        const pollsLayer = layers.find((l: any) => l.systemType === CalendarSystemType.POLLS);
        if (pollsLayer) {
          const item = await prisma.calendarItem.create({
            data: {
              userId: poll.createdByUserId,
              layerId: pollsLayer.id,
              title: poll.title,
              description: poll.description,
              location: poll.location,
              date: finalOption.date,
              startTime: finalOption.startTime,
              endTime: finalOption.endTime,
              sourceType: 'POLL',
              sourceId: poll.id,
            },
          });
          await prisma.schedulingPoll.update({
            where: { id: pollId },
            data: { convertedCalendarItemId: item.id },
          });
          logger.info(`Created calendar item ${item.id} from poll ${pollId}`);
        }
      } catch (err) {
        logger.error('Auto-convert to calendar failed', { error: err, pollId });
      }
    }

    // Auto-convert to Gancio event
    if (poll.autoConvertToGancio) {
      try {
        const { gancioClient } = await import('../../services/gancio.client');
        const eventId = await gancioClient.createEvent({
          title: poll.title,
          description: poll.description,
          location: poll.location,
          date: finalOption.date,
          startTime: finalOption.startTime,
          endTime: finalOption.endTime,
        });
        if (eventId) {
          await prisma.schedulingPoll.update({
            where: { id: pollId },
            data: { convertedGancioEventId: eventId },
          });
          logger.info(`Created Gancio event ${eventId} from poll ${pollId}`);
        }
      } catch (err) {
        logger.error('Auto-convert to Gancio failed', { error: err, pollId });
      }
    }

    // Auto-convert to Shift
    let createdShiftId: string | null = null;
    if (poll.autoConvertToShift) {
      try {
        const shift = await prisma.shift.create({
          data: {
            title: poll.title,
            description: poll.description,
            date: finalOption.date,
            startTime: finalOption.startTime,
            endTime: finalOption.endTime,
            location: poll.location,
            maxVolunteers: 10,
            isPublic: true,
          },
        });
        createdShiftId = shift.id;
        await prisma.schedulingPoll.update({
          where: { id: pollId },
          data: { convertedShiftId: shift.id },
        });
        logger.info(`Created shift ${shift.id} from poll ${pollId}`);

        // Auto-enroll YES voters into the shift
        this.autoEnrollVotersIntoShift(poll, shift.id, winningOptionId).catch((err) =>
          logger.error('Auto-enroll voters failed', { error: err, pollId })
        );
      } catch (err) {
        logger.error('Auto-convert to shift failed', { error: err, pollId });
      }
    }

    // Auto-create meeting agenda
    try {
      await prisma.meetingAgenda.create({
        data: {
          pollId,
          shiftId: createdShiftId,
          title: poll.title,
          createdByUserId: poll.createdByUserId,
          items: [
            { id: '1', title: 'Check-in / introductions', durationMinutes: 10, order: 0 },
            { id: '2', title: 'Report-backs on action items', durationMinutes: 10, order: 1 },
            { id: '3', title: `Main discussion: ${poll.title}`, durationMinutes: 30, order: 2 },
            { id: '4', title: 'Action items', durationMinutes: 10, order: 3 },
            { id: '5', title: 'Next steps', durationMinutes: 5, order: 4 },
          ] as unknown as Prisma.InputJsonValue,
        },
      });
      logger.info(`Created meeting agenda from poll ${pollId}`);
    } catch (err) {
      logger.error('Auto-create agenda failed', { error: err, pollId });
    }

    // Aggregate participant needs for organizer notification
    this.sendNeedsSummaryToOrganizer(poll).catch((err) =>
      logger.error('Failed to send needs summary', { error: err, pollId })
    );
  },

  /**
   * Find-or-create a Contact for a poll voter and log the vote as an activity.
   */
  async linkVoterToContact(
    pollId: string,
    pollTitle: string,
    voterName: string,
    voterEmail: string,
    optionIds: string[],
    userId?: string,
    voterToken?: string,
    participantNeeds?: UpsertNeedsInput,
  ) {
    const normalizedEmail = voterEmail.trim().toLowerCase();

    // Find existing contact by email
    let contact = await prisma.contact.findFirst({
      where: {
        OR: [
          { email: normalizedEmail },
          { emails: { some: { email: normalizedEmail } } },
        ],
      },
    });

    if (!contact) {
      // Create new contact
      const nameParts = voterName.trim().split(/\s+/);
      contact = await prisma.contact.create({
        data: {
          displayName: voterName,
          firstName: nameParts[0] || null,
          lastName: nameParts.length > 1 ? nameParts.slice(1).join(' ') : null,
          email: normalizedEmail,
          primarySource: 'POLL_VOTE',
          userId: userId || null,
        },
      });
      // Also create ContactEmail entry
      await prisma.contactEmail.create({
        data: {
          contactId: contact.id,
          email: normalizedEmail,
          label: 'Primary',
          isPrimary: true,
        },
      }).catch(() => {}); // ignore if duplicate
    }

    // Log activity
    await prisma.contactActivity.create({
      data: {
        contactId: contact.id,
        type: 'POLL_VOTED',
        title: `Voted on: ${pollTitle}`,
        metadata: { pollId, optionIds } as unknown as Prisma.InputJsonValue,
      },
    });

    // Update vote records with contactId
    const whereClause = userId
      ? { pollId, userId }
      : voterToken
        ? { pollId, voterToken }
        : { pollId, voterName, userId: null, voterToken: null };
    await prisma.schedulingPollVote.updateMany({
      where: whereClause,
      data: { contactId: contact.id },
    });

    // Upsert participant needs if provided
    if (participantNeeds && Object.keys(participantNeeds).length > 0) {
      await participantNeedsService.upsert(
        participantNeeds,
        userId,
        contact.id,
      );
    }
  },

  /**
   * Auto-enroll YES voters into a shift created from poll finalization.
   */
  async autoEnrollVotersIntoShift(poll: any, shiftId: string, winningOptionId: string) {
    const yesVotes = await prisma.schedulingPollVote.findMany({
      where: {
        pollId: poll.id,
        optionId: winningOptionId,
        value: 'YES',
        voterEmail: { not: null },
      },
    });

    if (yesVotes.length === 0) return;

    const shift = await prisma.shift.findUnique({ where: { id: shiftId } });
    if (!shift) return;

    for (const vote of yesVotes) {
      if (!vote.voterEmail) continue;

      try {
        // Check if already signed up
        const existing = await prisma.shiftSignup.findUnique({
          where: { shiftId_userEmail: { shiftId, userEmail: vote.voterEmail } },
        });
        if (existing) continue;

        await prisma.shiftSignup.create({
          data: {
            shiftId,
            shiftTitle: shift.title,
            userId: vote.userId,
            userEmail: vote.voterEmail,
            userName: vote.voterName,
            signupSource: 'POLL_CONVERSION',
          },
        });

        // Increment volunteer count
        await prisma.shift.update({
          where: { id: shiftId },
          data: { currentVolunteers: { increment: 1 } },
        });

        if (poll.autoEnrollVoters) {
          // Send enrollment notification
          const dateStr = new Date(shift.date).toLocaleDateString('en-CA', {
            weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
          });
          await emailService.sendEmail({
            to: vote.voterEmail,
            subject: `You're signed up for "${shift.title}"`,
            html: `<p>Based on your poll vote, you've been automatically signed up for "<strong>${escapeHtml(shift.title)}</strong>".</p>
              <p><strong>${dateStr}</strong><br/>${shift.startTime} - ${shift.endTime}</p>
              ${shift.location ? `<p>Location: ${escapeHtml(shift.location)}</p>` : ''}`,
            text: `Based on your poll vote, you've been automatically signed up for "${shift.title}".\n${dateStr}\n${shift.startTime} - ${shift.endTime}${shift.location ? `\nLocation: ${shift.location}` : ''}`,
          }).catch((err) => logger.error('Failed to send auto-enroll email', { error: err }));
        }
      } catch (err) {
        logger.error('Failed to auto-enroll voter', { error: err, voterEmail: vote.voterEmail });
      }
    }
  },

  /**
   * Aggregate participant needs for YES voters and email the organizer a prep summary.
   */
  async sendNeedsSummaryToOrganizer(poll: any) {
    // Get all YES voter user/contact IDs on the winning option
    const yesVotes = await prisma.schedulingPollVote.findMany({
      where: { pollId: poll.id, optionId: poll.finalizedOptionId, value: 'YES' },
      select: { userId: true, contactId: true },
    });

    const userIds = yesVotes.map(v => v.userId).filter(Boolean) as string[];
    const contactIds = yesVotes.map(v => v.contactId).filter(Boolean) as string[];

    if (userIds.length === 0 && contactIds.length === 0) return;

    const needs = await prisma.participantNeeds.findMany({
      where: {
        OR: [
          ...(userIds.length ? [{ userId: { in: userIds } }] : []),
          ...(contactIds.length ? [{ contactId: { in: contactIds } }] : []),
        ],
      },
    });

    if (needs.length === 0) return;

    // Aggregate
    const summary: string[] = [];
    const wheelchair = needs.filter(n => n.needsWheelchair).length;
    const groundFloor = needs.filter(n => n.needsGroundFloor).length;
    const hearingLoop = needs.filter(n => n.needsHearingLoop).length;
    const signLanguage = needs.filter(n => n.needsSignLanguage).length;
    const childcare = needs.filter(n => n.needsChildcare).length;
    const transportation = needs.filter(n => n.needsTransportation).length;
    const vegan = needs.filter(n => n.isVegan).length;
    const vegetarian = needs.filter(n => n.isVegetarian).length;
    const glutenFree = needs.filter(n => n.isGlutenFree).length;
    const halal = needs.filter(n => n.isHalal).length;
    const kosher = needs.filter(n => n.isKosher).length;
    const nutAllergy = needs.filter(n => n.hasNutAllergy).length;
    const translation = needs.filter(n => n.needsTranslation).length;

    if (wheelchair) summary.push(`${wheelchair} need wheelchair access`);
    if (groundFloor) summary.push(`${groundFloor} need ground-floor access`);
    if (hearingLoop) summary.push(`${hearingLoop} need hearing loop`);
    if (signLanguage) summary.push(`${signLanguage} need sign language interpretation`);
    if (childcare) summary.push(`${childcare} need childcare`);
    if (transportation) summary.push(`${transportation} need transportation`);
    if (vegan) summary.push(`${vegan} vegan`);
    if (vegetarian) summary.push(`${vegetarian} vegetarian`);
    if (glutenFree) summary.push(`${glutenFree} gluten-free`);
    if (halal) summary.push(`${halal} halal`);
    if (kosher) summary.push(`${kosher} kosher`);
    if (nutAllergy) summary.push(`${nutAllergy} nut allergy`);
    if (translation) summary.push(`${translation} need translation`);

    if (summary.length === 0) return;

    // Send to organizer
    const organizerEmail = poll.createdBy?.email;
    if (!organizerEmail) return;

    await emailService.sendEmail({
      to: organizerEmail,
      subject: `Prep checklist for "${poll.title}"`,
      html: `<p>Participant needs for "<strong>${escapeHtml(poll.title)}</strong>":</p>
        <ul>${summary.map(s => `<li>${escapeHtml(s)}</li>`).join('')}</ul>`,
      text: `Participant needs for "${poll.title}":\n${summary.map(s => `- ${s}`).join('\n')}`,
    });
  },

  async notifyOrganizer(email: string, pollTitle: string, voterName: string) {
    try {
      await emailService.sendEmail({
        to: email,
        subject: `New vote on "${pollTitle}"`,
        html: `<p><strong>${escapeHtml(voterName)}</strong> voted on your scheduling poll "<strong>${escapeHtml(pollTitle)}</strong>".</p>`,
        text: `${voterName} voted on your scheduling poll "${pollTitle}".`,
      });
    } catch (err) {
      logger.error('Failed to send vote notification email', { error: err });
    }
  },

  async notifyVotersFinalized(poll: any) {
    const finalOption = poll.options.find((o: any) => o.id === poll.finalizedOptionId);
    if (!finalOption) return;

    const dateStr = new Date(finalOption.date).toLocaleDateString('en-CA', {
      weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
    });
    const timeStr = `${finalOption.startTime} - ${finalOption.endTime}`;

    // Collect unique voter emails
    const voterEmails = new Set<string>();
    for (const opt of poll.options) {
      for (const vote of opt.votes) {
        if (vote.voterEmail) voterEmails.add(vote.voterEmail);
      }
    }

    for (const email of voterEmails) {
      try {
        await emailService.sendEmail({
          to: email,
          subject: `Date confirmed for "${poll.title}"`,
          html: `<p>The date for "<strong>${escapeHtml(poll.title)}</strong>" has been confirmed:</p>
            <p><strong>${dateStr}</strong><br/>${timeStr}</p>
            ${poll.location ? `<p>Location: ${escapeHtml(poll.location)}</p>` : ''}`,
          text: `The date for "${poll.title}" has been confirmed:\n${dateStr}\n${timeStr}${poll.location ? `\nLocation: ${poll.location}` : ''}`,
        });
      } catch (err) {
        logger.error('Failed to send finalization email', { error: err, email });
      }
    }
  },
};

function generateVoterToken(): string {
  return crypto.randomBytes(18).toString('base64url').slice(0, 24);
}

function escapeHtml(str: string): string {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}
