import { Injectable } from '@nestjs/common';
import { PrismaService } from '@core/database/prisma/prisma.service';
import { MeetingAttendanceError } from '../errors/meeting-attendance.error';
import { SeriesRepository } from '../repositories/series.repository';
import { MeetingRepository } from '../repositories/meeting.repository';
import { RequiredAttendeeRepository } from '../repositories/required-attendee.repository';
import { AttendeeRole, MeetingStatus, OutlookBindingSyncMode, Prisma } from '@prisma/client';
import { deriveMeetingStatus, generateDualQRCodes, getMeetingAttendanceBaseUrl } from '../utils/meeting-utils';
import { convertToUTC } from '../utils/timezone-utils';
import { mapUserWithPrimaryOrg } from '../utils/user-mapping';
import { canMutateSeries } from '../utils/meeting-roles';
import { SkipAssertAccess } from '@common/decorators/skip-assert-access.decorator';
import { Request } from 'express';
import {
  MEETING_ATTENDANCE_AUDIT_ACTIONS,
  MEETING_ATTENDANCE_AUDIT_RESOURCES,
} from '../constants/audit';
import { MeetingAttendanceAuditLogWriter } from './audit-log-writer.service';

const TENANT_DEFAULT_TIMEZONE = 'America/Los_Angeles';

@Injectable()
export class SeriesService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly seriesRepository: SeriesRepository,
    private readonly meetingRepository: MeetingRepository,
    private readonly requiredAttendeeRepository: RequiredAttendeeRepository,
    private readonly auditLogWriter: MeetingAttendanceAuditLogWriter,
  ) {}

  async listSeries() {
    const series = await this.seriesRepository.listSeries();
    const now = new Date();
    const updatePromises: Promise<void>[] = [];

    for (const seriesItem of series) {
      for (const meeting of seriesItem.meetings) {
        const derivedStatus = deriveMeetingStatus(
          meeting.status as MeetingStatus,
          meeting.startTime,
          meeting.endTime,
          now,
        );

        if (derivedStatus !== meeting.status) {
          const updatePromise = this.meetingRepository
            .updateMeetingStatus(meeting.id, derivedStatus)
            .then(() => {
              meeting.status = derivedStatus;
            })
            .catch(() => undefined);
          updatePromises.push(updatePromise);
        }
      }
    }

    await Promise.all(updatePromises);
    return { series };
  }

  async createSeries(
    payload: {
      title?: string;
      description?: string;
      pattern?: string;
      frequency?: number;
      startDate?: string;
      endDate?: string;
      maxOccurrences?: number;
      location?: string;
      type?: string;
      timezone?: string;
      duration?: number;
      city?: string;
      enforceCheckinMode?: boolean;
      attendees?: Array<{
        userId?: string;
        role?: string;
      }>;
    },
    request: Request | undefined,
    userEmail: string,
    actor?: { userId?: string; id?: string; email?: string; displayName?: string; username?: string; roles?: any[] },
  ) {
    if (!payload.title || !payload.pattern || !payload.startDate || !payload.duration) {
      throw new MeetingAttendanceError(
        400,
        'Series title, recurrence pattern, start date, and meeting duration are required',
      );
    }

    // v1.2 guard
    const normalizedCity = payload.city?.trim() || null;
    const enforceCheckinMode = payload.enforceCheckinMode ?? false;
    if (enforceCheckinMode && !normalizedCity) {
      throw new MeetingAttendanceError(400, '开启签到方式校验前请先配置会议地点', 'MEETING_ATTENDANCE_036');
    }

    const user = await this.prisma.user.findUnique({ where: { email: userEmail, deletedAt: null } });
    if (!user) {
      throw new MeetingAttendanceError(404, 'User does not exist');
    }

    const series = await this.seriesRepository.createSeries({
      title: payload.title,
      description: payload.description,
      pattern: payload.pattern as any,
      frequency: payload.frequency || 1,
      startDate: new Date(payload.startDate),
      endDate: payload.endDate ? new Date(payload.endDate) : null,
      maxOccurrences: payload.maxOccurrences,
      timezone: payload.timezone || TENANT_DEFAULT_TIMEZONE,
      location: payload.location,
      type: (payload.type as any) || 'OFFLINE',
      creator: { connect: { id: user.id } },
      city: normalizedCity,
      enforceCheckinMode,
    } as any);

    const durationMinutes = Number(payload.duration);
    if (Number.isNaN(durationMinutes) || durationMinutes <= 0) {
      throw new MeetingAttendanceError(400, 'Meeting duration must be a positive number');
    }
    const instances = this.generateMeetingInstances(
      {
        startDate: series.startDate.toISOString(),
        endDate: series.endDate?.toISOString(),
        pattern: series.pattern,
        frequency: series.frequency,
      },
      durationMinutes,
      payload.maxOccurrences || 10,
    );

    const baseUrl = getMeetingAttendanceBaseUrl(request);
    const createdMeetings = [] as any[];

    for (let i = 0; i < instances.length; i += 1) {
      const instance = instances[i];
      const formatter = new Intl.DateTimeFormat('en-US', {
        timeZone: payload.timezone || TENANT_DEFAULT_TIMEZONE,
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
      });
      const formattedDate = formatter.format(instance.startTime);

      const meeting = await this.prisma.meeting.create({
        data: {
          title: `${payload.title} - Session ${i + 1} (${formattedDate})`,
          description: payload.description,
          startTime: instance.startTime,
          endTime: instance.endTime,
          location: payload.location,
          type: (payload.type as any) || 'OFFLINE',
          timezone: payload.timezone || TENANT_DEFAULT_TIMEZONE,
          creatorId: user.id,
          seriesId: series.id,
          instanceNumber: i + 1,
          isSeriesMaster: i === 0,
          city: normalizedCity,
          enforceCheckinMode,
        } as any,
      });

      const qrCodes = await generateDualQRCodes(meeting.id, baseUrl);
      await this.prisma.meeting.update({
        where: { id: meeting.id },
        data: {
          qrCodeOnline: qrCodes.online,
          qrCodeOffline: qrCodes.offline,
        },
      });

      createdMeetings.push({ ...meeting, qrCodeOnline: qrCodes.online, qrCodeOffline: qrCodes.offline });
    }

    if (payload.attendees && Array.isArray(payload.attendees) && payload.attendees.length > 0) {
      for (const meeting of createdMeetings) {
        const attendeeData = payload.attendees.map((attendee) => {
          if (!attendee.userId) {
            throw new MeetingAttendanceError(400, 'Attendee userId is required');
          }

          return {
            meetingId: meeting.id,
            userId: attendee.userId,
            role: this.normalizeAttendeeRole(attendee.role),
          };
        });

        await this.prisma.meetingRequiredAttendee.createMany({ data: attendeeData });
      }
    }

    await this.auditLogWriter.log({
      request,
      actor,
      action: MEETING_ATTENDANCE_AUDIT_ACTIONS.SERIES_CREATE,
      resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.SERIES,
      statusCode: 201,
      resourceId: series.id,
      requestBody: {
        title: payload.title,
        pattern: payload.pattern,
        frequency: payload.frequency,
        startDate: payload.startDate,
        endDate: payload.endDate,
        maxOccurrences: payload.maxOccurrences,
        location: payload.location,
        type: payload.type,
        timezone: payload.timezone,
        duration: payload.duration,
      },
      changes: {
        action: 'create',
        newData: {
          seriesId: series.id,
          title: series.title,
          pattern: series.pattern,
          meetingCount: createdMeetings.length,
          firstMeeting: createdMeetings[0]?.startTime,
          lastMeeting: createdMeetings[createdMeetings.length - 1]?.endTime,
        },
      },
    });

    return {
      series,
      meetings: createdMeetings,
      message: 'Meeting series created successfully',
    };
  }

  async getSeriesDetails(id: string) {
    const series = await this.seriesRepository.findSeriesWithMeetings(id);
    if (!series) {
      throw new MeetingAttendanceError(404, 'Meeting series not found');
    }
    return {
      ...series,
      creator: mapUserWithPrimaryOrg(series.creator),
      meetings: series.meetings.map((meeting) => ({
        ...meeting,
        attendances: meeting.attendances.map((attendance) => ({
          ...attendance,
          user: mapUserWithPrimaryOrg(attendance.user),
        })),
        requiredAttendees: meeting.requiredAttendees.map((attendee) => ({
          ...attendee,
          user: mapUserWithPrimaryOrg(attendee.user),
        })),
      })),
    };
  }

  async updateSeries(
    id: string,
    payload: {
      title?: string;
      description?: string;
      location?: string;
      isActive?: boolean;
      pattern?: string;
      frequency?: number;
      endDate?: string;
      maxOccurrences?: number;
      type?: string;
      timezone?: string;
      city?: string | null;
      enforceCheckinMode?: boolean;
    },
    request?: Request,
    actor?: { userId?: string; id?: string; email?: string; displayName?: string; username?: string; roles?: any[] },
  ) {
    const originalSeries = await this.seriesRepository.findSeriesById(id);
    if (!originalSeries) {
      throw new MeetingAttendanceError(404, 'Meeting series not found');
    }

    // v1.2 guard: 开启签到方式校验前必须有地点
    const nextEnforce =
      payload.enforceCheckinMode ?? (originalSeries as any).enforceCheckinMode ?? false;
    const nextCity =
      payload.city !== undefined
        ? payload.city?.trim() || null
        : (originalSeries as any).city ?? null;
    if (nextEnforce && !nextCity) {
      throw new MeetingAttendanceError(400, '开启签到方式校验前请先配置会议地点', 'MEETING_ATTENDANCE_036');
    }

    const updateSeriesData: any = {
      title: payload.title,
      description: payload.description,
      location: payload.location,
      isActive: payload.isActive,
      pattern: payload.pattern as any,
      frequency: payload.frequency,
      endDate: payload.endDate ? new Date(payload.endDate) : null,
      maxOccurrences: payload.maxOccurrences,
      type: payload.type as any,
      timezone: payload.timezone,
    };
    if (payload.city !== undefined) updateSeriesData.city = nextCity;
    if (payload.enforceCheckinMode !== undefined) updateSeriesData.enforceCheckinMode = payload.enforceCheckinMode;

    const updatedSeries = await this.seriesRepository.updateSeries(id, updateSeriesData);

    if (payload.description !== undefined || payload.location !== undefined) {
      const updateData: { description?: string | null; location?: string | null } = {};
      if (payload.description !== undefined) updateData.description = payload.description;
      if (payload.location !== undefined) updateData.location = payload.location;
      await this.seriesRepository.updateMeetingsForSeries(id, updateData);
    }

    // v1.2 系列 city 或 enforceCheckinMode 变更 → 级联刷新所有下属 meeting 同字段（含 Outlook 同步进来的实例）
    if (payload.city !== undefined || payload.enforceCheckinMode !== undefined) {
      const meetingUpdateData: any = {};
      if (payload.city !== undefined) meetingUpdateData.city = nextCity;
      if (payload.enforceCheckinMode !== undefined) meetingUpdateData.enforceCheckinMode = payload.enforceCheckinMode;
      await this.prisma.meeting.updateMany({ where: { seriesId: id }, data: meetingUpdateData });
    }

    let newMeetingsCreated = 0;
    if (payload.maxOccurrences && payload.maxOccurrences > (originalSeries.maxOccurrences || 0)) {
      const existingMeetings = await this.prisma.meeting.findMany({
        where: { seriesId: id },
        orderBy: { instanceNumber: 'desc' },
        take: 1,
      });

      const currentMaxInstance =
        existingMeetings.length > 0 && existingMeetings[0].instanceNumber
          ? existingMeetings[0].instanceNumber
          : 0;
      const meetingsToCreate = payload.maxOccurrences - currentMaxInstance;

      if (meetingsToCreate > 0) {
        const firstMeeting = await this.prisma.meeting.findFirst({
          where: { seriesId: id },
          orderBy: { instanceNumber: 'asc' },
        });

        if (firstMeeting) {
          const duration = Math.round(
            (new Date(firstMeeting.endTime).getTime() - new Date(firstMeeting.startTime).getTime()) / 60000,
          );

          const allInstances = this.generateMeetingInstances(
            {
              startDate: updatedSeries.startDate.toISOString(),
              endDate: updatedSeries.endDate?.toISOString(),
              pattern: updatedSeries.pattern,
              frequency: updatedSeries.frequency,
            },
            duration,
            payload.maxOccurrences,
          );

          const baseUrl = getMeetingAttendanceBaseUrl();
          const newInstances = allInstances.slice(currentMaxInstance);

          const defaultAttendees = await this.prisma.meetingRequiredAttendee.findMany({
            where: { meetingId: firstMeeting.id },
          });

          for (let i = 0; i < newInstances.length; i += 1) {
            const instance = newInstances[i];
            const formatter = new Intl.DateTimeFormat('en-US', {
              timeZone: updatedSeries.timezone || TENANT_DEFAULT_TIMEZONE,
              year: 'numeric',
              month: '2-digit',
              day: '2-digit',
            });
            const formattedDate = formatter.format(instance.startTime);

            const meeting = await this.prisma.meeting.create({
              data: {
                title: `${updatedSeries.title} - Session ${currentMaxInstance + i + 1} (${formattedDate})`,
                description: updatedSeries.description,
                startTime: instance.startTime,
                endTime: instance.endTime,
                location: updatedSeries.location,
                type: updatedSeries.type,
                timezone: updatedSeries.timezone,
                creatorId: updatedSeries.creatorId,
                seriesId: updatedSeries.id,
                instanceNumber: currentMaxInstance + i + 1,
                isSeriesMaster: false,
              },
            });

            const qrCodes = await generateDualQRCodes(meeting.id, baseUrl);
            await this.prisma.meeting.update({
              where: { id: meeting.id },
              data: { qrCodeOnline: qrCodes.online, qrCodeOffline: qrCodes.offline },
            });

            if (defaultAttendees.length > 0) {
              await this.prisma.meetingRequiredAttendee.createMany({
                data: defaultAttendees.map((attendee) => ({
                  meetingId: meeting.id,
                  userId: attendee.userId,
                  role: attendee.role,
                })),
              });
            }

            newMeetingsCreated += 1;
          }
        }
      }
    }

    await this.auditLogWriter.log({
      request,
      actor,
      action: MEETING_ATTENDANCE_AUDIT_ACTIONS.SERIES_UPDATE,
      resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.SERIES,
      statusCode: 200,
      resourceId: id,
      requestBody: payload as Record<string, unknown>,
      changes: {
        action: 'update',
        before: originalSeries,
        after: updatedSeries,
        newMeetingsCreated,
      },
    });

    return {
      ...updatedSeries,
      newMeetingsCreated,
      message:
        newMeetingsCreated > 0
          ? `Series updated and ${newMeetingsCreated} new meeting(s) created`
          : 'Series updated successfully',
    };
  }

  async deleteSeries(
    id: string,
    request?: Request,
    actor?: { userId?: string; id?: string; email?: string; displayName?: string; username?: string; roles?: any[] },
  ) {
    const series = await this.seriesRepository.findSeriesById(id);
    if (!series) {
      throw new MeetingAttendanceError(404, 'Meeting series not found');
    }

    const hasAttendances = await this.seriesRepository.countSeriesAttendance(id);

    if (hasAttendances) {
      await this.prisma.$transaction(async (tx) => {
        await tx.meetingSeries.update({ where: { id }, data: { isActive: false } });
        await tx.meeting.updateMany({
          where: { seriesId: id, status: { in: ['SCHEDULED', 'IN_PROGRESS'] } },
          data: { status: 'CANCELLED' },
        });
      });

      await this.auditLogWriter.log({
        request,
        actor,
        action: MEETING_ATTENDANCE_AUDIT_ACTIONS.SERIES_UPDATE,
        resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.SERIES,
        statusCode: 200,
        resourceId: id,
        changes: {
          action: 'deactivate',
          seriesTitle: series.title,
          reason: 'Has attendance records',
        },
      });

      return {
        message:
          'Meeting series has been disabled and all unfinished meetings have been cancelled (cannot be permanently deleted due to attendance records).',
        type: 'soft_delete',
      };
    }

    await this.seriesRepository.deleteSeries(id);
    await this.auditLogWriter.log({
      request,
      actor,
      action: MEETING_ATTENDANCE_AUDIT_ACTIONS.SERIES_DELETE,
      resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.SERIES,
      statusCode: 200,
      resourceId: id,
      changes: {
        action: 'delete',
        seriesTitle: series.title,
        deletedData: series,
      },
    });
    return {
      message: 'Meeting series and all related meeting instances have been deleted successfully.',
      type: 'hard_delete',
    };
  }

  async getSeriesHistory(id: string) {
    const completedMeetings = await this.prisma.meeting.findMany({
      where: { seriesId: id, status: 'COMPLETED' },
      include: {
        attendances: {
          include: {
            user: {
              select: {
                id: true,
                displayName: true,
                email: true,
                departmentMemberships: {
                  where: { leftAt: null },
                  include: {
                    department: { select: { id: true, name: true, code: true } },
                    position: { select: { id: true, name: true, level: true } },
                  },
                },
              },
            },
          },
        },
        requiredAttendees: {
          include: {
            user: {
              select: {
                id: true,
                displayName: true,
                email: true,
                departmentMemberships: {
                  where: { leftAt: null },
                  include: {
                    department: { select: { id: true, name: true, code: true } },
                    position: { select: { id: true, name: true, level: true } },
                  },
                },
              },
            },
          },
        },
        creator: {
          select: {
            displayName: true,
            email: true,
            departmentMemberships: {
              where: { leftAt: null },
              include: {
                department: { select: { id: true, name: true, code: true } },
                position: { select: { id: true, name: true, level: true } },
              },
            },
          },
        },
      },
      orderBy: { startTime: 'desc' },
    });

    const meetingsWithStats = completedMeetings.map((meeting) => {
      const normalizedMeeting = {
        ...meeting,
        creator: mapUserWithPrimaryOrg(meeting.creator),
        attendances: meeting.attendances.map((attendance) => ({
          ...attendance,
          user: mapUserWithPrimaryOrg(attendance.user),
        })),
        requiredAttendees: meeting.requiredAttendees.map((attendee) => ({
          ...attendee,
          user: mapUserWithPrimaryOrg(attendee.user),
        })),
      };
      const totalRequired = meeting.requiredAttendees.length;
      const actualAttendees = meeting.attendances.filter(
        (attendance) => attendance.status === 'ON_SITE' || attendance.status === 'ONLINE',
      ).length;
      const attendanceRate = totalRequired > 0 ? Math.round((actualAttendees / totalRequired) * 100) : 0;

      return {
        ...normalizedMeeting,
        stats: {
          totalRequired,
          actualAttendees,
          attendanceRate,
          absentees: totalRequired - actualAttendees,
        },
      };
    });

    return {
      meetings: meetingsWithStats,
      summary: {
        totalMeetings: completedMeetings.length,
        averageAttendanceRate:
          meetingsWithStats.length > 0
            ? Math.round(
                meetingsWithStats.reduce((sum, m) => sum + m.stats.attendanceRate, 0) /
                  meetingsWithStats.length,
              )
            : 0,
      },
    };
  }

  async updateSeriesSchedule(
    seriesId: string,
    payload: { startTime?: string; duration?: string; timezone?: string },
    request?: Request,
    actor?: { userId?: string; id?: string; email?: string; displayName?: string; username?: string; roles?: any[] },
  ) {
    if (!payload.startTime || !payload.duration || !payload.timezone) {
      throw new MeetingAttendanceError(400, 'Missing required fields: startTime, duration, and timezone');
    }

    const series = await this.prisma.meetingSeries.findUnique({
      where: { id: seriesId },
      include: {
        meetings: {
          where: { status: { in: ['SCHEDULED', 'IN_PROGRESS'] } },
          orderBy: { startTime: 'asc' },
        },
      },
    });

    if (!series) {
      throw new MeetingAttendanceError(404, 'Meeting series not found');
    }

    if (series.meetings.length === 0) {
      return {
        message: 'No meetings to update (all meetings are completed or cancelled)',
        updatedCount: 0,
      };
    }

    const [hours, minutes] = payload.startTime.split(':').map(Number);
    if (Number.isNaN(hours) || Number.isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
      throw new MeetingAttendanceError(400, 'Invalid time format. Expected HH:mm (e.g., "09:00")');
    }

    const durationMinutes = parseInt(payload.duration, 10);
    if (Number.isNaN(durationMinutes) || durationMinutes <= 0) {
      throw new MeetingAttendanceError(400, 'Invalid duration. Must be a positive number of minutes');
    }

    const baseUrl = getMeetingAttendanceBaseUrl();
    const updatedMeetings = [] as any[];
    const errors: Array<{ meetingId: string; title: string; error: string }> = [];

    await this.prisma.meetingSeries.update({ where: { id: seriesId }, data: { timezone: payload.timezone } });

    for (const meeting of series.meetings) {
      try {
        const originalStart = new Date(meeting.startTime);
        const formatter = new Intl.DateTimeFormat('en-CA', {
          timeZone: payload.timezone,
          year: 'numeric',
          month: '2-digit',
          day: '2-digit',
        });
        const datePart = formatter.format(originalStart);
        const localDateTimeStr = `${datePart}T${payload.startTime}:00`;

        const newStartUTC = convertToUTC(localDateTimeStr, payload.timezone);
        const newEndUTC = new Date(newStartUTC.getTime() + durationMinutes * 60 * 1000);

        await this.prisma.meeting.update({
          where: { id: meeting.id },
          data: { startTime: newStartUTC, endTime: newEndUTC, timezone: payload.timezone },
        });

        const qrCodes = await generateDualQRCodes(meeting.id, baseUrl);
        await this.prisma.meeting.update({
          where: { id: meeting.id },
          data: { qrCodeOnline: qrCodes.online, qrCodeOffline: qrCodes.offline },
        });

        updatedMeetings.push({
          id: meeting.id,
          title: meeting.title,
          oldStartTime: meeting.startTime,
          newStartTime: newStartUTC,
          oldEndTime: meeting.endTime,
          newEndTime: newEndUTC,
        });
      } catch (error) {
        errors.push({
          meetingId: meeting.id,
          title: meeting.title,
          error: error instanceof Error ? error.message : 'Unknown error',
        });
      }
    }

    await this.auditLogWriter.log({
      request,
      actor,
      action: MEETING_ATTENDANCE_AUDIT_ACTIONS.SERIES_UPDATE,
      resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.SERIES,
      statusCode: 200,
      resourceId: seriesId,
      requestBody: payload as Record<string, unknown>,
      changes: {
        action: 'update_schedule',
        updatedCount: updatedMeetings.length,
        errors: errors.length > 0 ? errors.slice(0, 10) : [],
      },
    });

    return {
      success: true,
      message: `Successfully updated ${updatedMeetings.length} meetings`,
      updatedCount: updatedMeetings.length,
      updatedMeetings,
      errors: errors.length > 0 ? errors : undefined,
    };
  }

  async getSeriesAttendees(seriesId: string, actor: { id: string; role: string; permissions?: string[] }) {
    const series = await this.seriesRepository.findSeriesWithMeetingsForAttendees(seriesId);
    if (!series) {
      throw new MeetingAttendanceError(404, 'Meeting series not found');
    }

    if (!canMutateSeries(actor) && series.creatorId !== actor.id) {
      throw new MeetingAttendanceError(403, 'Insufficient permissions');
    }

    const now = new Date();
    const upcomingMeetings = series.meetings
      .filter((meeting) => new Date(meeting.startTime) > now)
      .sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());

    const nextMeeting = upcomingMeetings.find((meeting) => !meeting.hasCustomAttendees);
    const defaultAttendees =
      (nextMeeting || series.meetings.find((meeting) => !meeting.hasCustomAttendees) || series.meetings[0])
        ?.requiredAttendees || [];

    // 过滤掉系列层"排除清单"里的用户：避免 Outlook 同步残留 / 时序差导致 UI 还显示已删除的人。
    const exceptions = await this.prisma.meetingSeriesAttendeeException.findMany({
      where: { seriesId },
      select: { userId: true },
    });
    const excludedUserIds = new Set(exceptions.map((ex) => ex.userId));

    const meetingsWithAttendees = series.meetings.map((meeting) => ({
      id: meeting.id,
      title: meeting.title,
      startTime: meeting.startTime,
      instanceNumber: meeting.instanceNumber,
      hasCustomAttendees: meeting.hasCustomAttendees,
      attendees: meeting.requiredAttendees
        .filter((ra) => !excludedUserIds.has(ra.userId))
        .map((ra) => ({
          id: ra.id,
          role: ra.role,
          user: mapUserWithPrimaryOrg(ra.user),
        })),
    }));

    return {
      series: { id: series.id, title: series.title, description: series.description },
      defaultAttendees: defaultAttendees
        .filter((ra) => !excludedUserIds.has(ra.userId))
        .map((ra) => ({
          id: ra.id,
          role: ra.role,
          user: mapUserWithPrimaryOrg(ra.user),
        })),
      meetings: meetingsWithAttendees,
    };
  }

  async setSeriesAttendees(
    seriesId: string,
    payload: { attendees?: any[]; action?: string },
    actor: { id: string; role: string; permissions?: string[] },
  ) {
    const series = await this.prisma.meetingSeries.findUnique({
      where: { id: seriesId },
      include: { meetings: true },
    });

    if (!series) {
      throw new MeetingAttendanceError(404, 'Meeting series not found');
    }

    if (!canMutateSeries(actor) && series.creatorId !== actor.id) {
      throw new MeetingAttendanceError(403, 'Insufficient permissions');
    }

    const action = payload.action || 'replace';
    const now = new Date();
    const applicableMeetings = series.meetings.filter((meeting) => {
      const meetingStartTime = new Date(meeting.startTime);
      return (meetingStartTime >= now || meeting.status === 'IN_PROGRESS') && !meeting.hasCustomAttendees;
    });

    for (const meeting of applicableMeetings) {
      if (action === 'add') {
        if (payload.attendees && payload.attendees.length > 0) {
          const existingAttendees = await this.prisma.meetingRequiredAttendee.findMany({
            where: { meetingId: meeting.id },
          });

          const newAttendees = payload.attendees.filter((newAttendee) => {
            return !existingAttendees.some((existing) => existing.userId === newAttendee.userId);
          });

          if (newAttendees.length > 0) {
            await this.prisma.meetingRequiredAttendee.createMany({
              data: newAttendees.map((attendee) => {
                if (!attendee.userId) {
                  throw new MeetingAttendanceError(400, 'Attendee userId is required');
                }

                return {
                  meetingId: meeting.id,
                  userId: attendee.userId,
                  role: this.normalizeAttendeeRole(attendee.role),
                };
              }),
            });
          }
        }
      } else {
        await this.prisma.meetingRequiredAttendee.deleteMany({ where: { meetingId: meeting.id } });

        if (payload.attendees && payload.attendees.length > 0) {
          const requiredAttendeeData = payload.attendees.map((attendee) => {
            if (!attendee.userId) {
              throw new MeetingAttendanceError(400, 'Attendee userId is required');
            }

            return {
              meetingId: meeting.id,
              userId: attendee.userId,
              role: this.normalizeAttendeeRole(attendee.role),
            };
          });

          await this.prisma.meetingRequiredAttendee.createMany({ data: requiredAttendeeData });

          const attendanceData = requiredAttendeeData
            .filter((attendee) => attendee.userId)
            .map((attendee) => ({
              userId: attendee.userId!,
              meetingId: meeting.id,
              status: 'NOT_CHECKED_IN' as const,
              checkinTime: null,
              checkinType: null,
              notes: null,
              isLate: false,
              isEarlyLeave: false,
            }));

          if (attendanceData.length > 0) {
            await this.prisma.meetingAttendance.createMany({ data: attendanceData });
          }
        }
      }

      await this.prisma.meeting.update({ where: { id: meeting.id }, data: { hasCustomAttendees: false } });
    }

    return { message: 'Attendees set successfully' };
  }

  async updateSeriesAttendeeRole(
    seriesId: string,
    payload: { attendeeId?: string; newRole?: string },
    actor: { id: string; role: string; permissions?: string[] },
  ) {
    if (!payload.attendeeId || !payload.newRole) {
      throw new MeetingAttendanceError(400, 'Missing attendee ID or new role');
    }

    const validRoles = [
      'CORE',
      'REQUIRED',
      'OPTIONAL_ATTENDEE',
      'REGULAR_ATTENDEE',
      'C_SUITE_ATTENDEE',
      'LEVEL_1_REPORT_ATTENDEE',
      'LEVEL_2_REPORT_ATTENDEE',
      'MEETING_OPERATIONS',
    ];

    if (!validRoles.includes(payload.newRole)) {
      throw new MeetingAttendanceError(
        400,
        `Invalid role: ${payload.newRole}. Valid roles are: ${validRoles.join(', ')}`,
      );
    }

    const series = await this.prisma.meetingSeries.findUnique({
      where: { id: seriesId },
      include: { meetings: true },
    });

    if (!series) {
      throw new MeetingAttendanceError(404, 'Meeting series not found');
    }

    if (!canMutateSeries(actor) && series.creatorId !== actor.id) {
      throw new MeetingAttendanceError(403, 'Insufficient permissions');
    }

    const attendeeToUpdate = await this.prisma.meetingRequiredAttendee.findUnique({
      where: { id: payload.attendeeId },
    });

    if (!attendeeToUpdate) {
      throw new MeetingAttendanceError(404, 'Attendee not found');
    }

    const now = new Date();
    const applicableMeetings = series.meetings.filter((meeting) => {
      const meetingStartTime = new Date(meeting.startTime);
      return (meetingStartTime >= now || meeting.status === 'IN_PROGRESS') && !meeting.hasCustomAttendees;
    });

    let updatedCount = 0;
    for (const meeting of applicableMeetings) {
      const updateResult = await this.prisma.meetingRequiredAttendee.updateMany({
        where: {
          meetingId: meeting.id,
          userId: attendeeToUpdate.userId,
        },
        data: { role: this.normalizeAttendeeRole(payload.newRole) },
      });
      updatedCount += updateResult.count;
    }

    return {
      success: true,
      message: `Updated role to ${payload.newRole} for ${updatedCount} attendee records`,
      updatedCount,
      affectedMeetings: applicableMeetings.length,
      newRole: payload.newRole,
      attendeeInfo: {
        userId: attendeeToUpdate.userId,
      },
    };
  }

  @SkipAssertAccess('系列层鉴权在方法开头由 canMutateSeries(actor) || series.creatorId === actor.id 兜底；操作目标按 seriesId 级联到下属 meetings')
  async deleteSeriesAttendee(seriesId: string, payload: { attendeeId?: string }, actor: { id: string; role: string; permissions?: string[] }) {
    if (!payload.attendeeId) {
      throw new MeetingAttendanceError(400, 'Missing attendee ID');
    }

    const series = await this.prisma.meetingSeries.findUnique({
      where: { id: seriesId },
      include: { meetings: true },
    });

    if (!series) {
      throw new MeetingAttendanceError(404, 'Meeting series not found');
    }

    if (!canMutateSeries(actor) && series.creatorId !== actor.id) {
      throw new MeetingAttendanceError(403, 'Insufficient permissions');
    }

    const attendeeToDelete = await this.prisma.meetingRequiredAttendee.findUnique({
      where: { id: payload.attendeeId },
    });

    if (!attendeeToDelete) {
      throw new MeetingAttendanceError(404, 'Attendee not found');
    }

    const now = new Date();
    // cascade 到所有未来 / 进行中的单次会议，**不再用 hasCustomAttendees 过滤**：
    // 系列层删除是用户明示决策，应穿透到所有未来场次（含被单独自定义过参会人的会议）。
    const applicableMeetings = series.meetings.filter((meeting) => {
      const meetingStartTime = new Date(meeting.startTime);
      return meetingStartTime >= now || meeting.status === 'IN_PROGRESS';
    });

    const userIdToExclude = attendeeToDelete.userId;
    let deletedCount = 0;

    await this.prisma.$transaction(async (tx) => {
      // 1) 持久化"系列层排除"，下次 Outlook 同步不会回填。已存在则不动 createdAt。
      await tx.meetingSeriesAttendeeException.upsert({
        where: { seriesId_userId: { seriesId, userId: userIdToExclude } },
        create: { seriesId, userId: userIdToExclude, excludedBy: actor.id },
        update: {},
      });

      // 2) 删现有所有未来 / 进行中会议的参会人行，让效果立即可见。
      for (const meeting of applicableMeetings) {
        const deleteResult = await tx.meetingRequiredAttendee.deleteMany({
          where: { meetingId: meeting.id, userId: userIdToExclude },
        });
        deletedCount += deleteResult.count;
      }
    });

    return {
      success: true,
      message: `Removed attendee from ${deletedCount} attendee records`,
      deletedCount,
      affectedMeetings: applicableMeetings.length,
      attendeeInfo: { userId: userIdToExclude },
    };
  }

  async updateSeriesMeeting(
    seriesId: string,
    meetingId: string,
    payload: {
      title?: string;
      description?: string;
      startTime?: string;
      endTime?: string;
      location?: string;
      status?: string;
      reason?: string;
    },
    actor?: { userId?: string; id?: string; email?: string },
  ) {
    const meeting = await this.prisma.meeting.findFirst({ where: { id: meetingId, seriesId } });
    if (!meeting) {
      throw new MeetingAttendanceError(404, 'Meeting does not exist or does not belong to the series');
    }

    const updatedMeeting = await this.prisma.meeting.update({
      where: { id: meetingId },
      data: {
        title: payload.title || meeting.title,
        description: payload.description || meeting.description,
        startTime: payload.startTime ? new Date(payload.startTime) : meeting.startTime,
        endTime: payload.endTime ? new Date(payload.endTime) : meeting.endTime,
        location: payload.location || meeting.location,
        status: (payload.status as MeetingStatus) || meeting.status,
      },
    });

    if (payload.startTime || payload.endTime) {
      const baseUrl = getMeetingAttendanceBaseUrl();
      const qrCodes = await generateDualQRCodes(meetingId, baseUrl);
      await this.prisma.meeting.update({
        where: { id: meetingId },
        data: { qrCodeOnline: qrCodes.online, qrCodeOffline: qrCodes.offline },
      });
      (updatedMeeting as any).qrCodeOnline = qrCodes.online;
      (updatedMeeting as any).qrCodeOffline = qrCodes.offline;
    }

    await this.lockOutlookBindingForMeeting(
      meetingId,
      actor,
      'MANUAL_SERIES_OCCURRENCE_EDIT',
      Object.keys(payload).filter((key) => payload[key as keyof typeof payload] !== undefined),
    );

    return { meeting: updatedMeeting, message: 'Meeting updated successfully' };
  }

  async cancelSeriesMeeting(seriesId: string, meetingId: string) {
    const meeting = await this.prisma.meeting.findFirst({ where: { id: meetingId, seriesId } });
    if (!meeting) {
      throw new MeetingAttendanceError(404, 'Meeting does not exist or does not belong to the series');
    }

    await this.prisma.meeting.update({ where: { id: meetingId }, data: { status: 'CANCELLED' } });
    return { message: 'Meeting cancelled' };
  }

  private normalizeAttendeeRole(role?: string | null): AttendeeRole {
    if (!role) {
      return AttendeeRole.REGULAR_ATTENDEE;
    }

    if (role === 'CORE') {
      return AttendeeRole.REGULAR_ATTENDEE;
    }

    const upperRole = role.toUpperCase();
    if (upperRole === 'OPTIONAL') {
      return AttendeeRole.OPTIONAL_ATTENDEE;
    }

    return upperRole as AttendeeRole;
  }

  private async lockOutlookBindingForMeeting(
    meetingId: string,
    actor: { userId?: string; id?: string; email?: string } | undefined,
    reason: string,
    fields: string[],
  ) {
    const binding = await this.prisma.outlookMeetingBinding.findFirst({
      where: {
        meetingId,
        manageStatus: {
          in: ['MANAGED', 'SYNC_ERROR'],
        },
      },
      select: {
        id: true,
        primaryMailboxId: true,
      },
    });
    if (!binding) {
      return;
    }

    await this.prisma.outlookMeetingBinding.update({
      where: { id: binding.id },
      data: {
        syncMode: OutlookBindingSyncMode.LOCKED_BY_LOCAL_EDIT,
        localOverrideAt: new Date(),
        localOverrideByUserId: actor?.userId || actor?.id || null,
        localOverrideByEmail: actor?.email || null,
        localOverrideReason: reason,
        localOverrideFields: fields as unknown as Prisma.InputJsonValue,
      },
    });

    await (this.prisma as any).outlookSyncEventLog.create({
      data: {
        bindingId: binding.id,
        mailboxId: binding.primaryMailboxId,
        eventType: 'LOCAL_OVERRIDE_SET',
        resultStatus: 'INFO',
        message: 'Meeting switched to local maintenance mode',
        payload: {
          reason,
          changedFields: fields,
          triggerSource: 'WEB',
          actorEmail: actor?.email || null,
        },
      },
    });
  }

  private generateMeetingInstances(
    series: { startDate: string; endDate?: string; pattern: string; frequency: number },
    duration: number,
    maxCount: number,
  ) {
    const instances: Array<{ startTime: Date; endTime: Date }> = [];
    const currentDate = new Date(series.startDate);
    const endDate = series.endDate ? new Date(series.endDate) : null;

    for (let i = 0; i < maxCount; i += 1) {
      if (endDate && currentDate > endDate) break;

      const startTime = new Date(currentDate);
      const endTime = new Date(currentDate.getTime() + duration * 60 * 1000);

      instances.push({ startTime, endTime });

      switch (series.pattern) {
        case 'DAILY':
          currentDate.setDate(currentDate.getDate() + series.frequency);
          break;
        case 'WEEKLY':
          currentDate.setDate(currentDate.getDate() + 7 * series.frequency);
          break;
        case 'MONTHLY':
          currentDate.setMonth(currentDate.getMonth() + series.frequency);
          break;
        case 'YEARLY':
          currentDate.setFullYear(currentDate.getFullYear() + series.frequency);
          break;
      }
    }

    return instances;
  }

  /**
   * v1.2 切换系列"签到方式校验"开关，级联刷新下属所有 meeting 同字段。
   * 开启前 guard：series.city 必须非空。
   * 独立 endpoint，不触发 Outlook 本地维护锁定。
   * v1.4：ON→OFF 时级联清空所有下属会议的 `MeetingRequiredAttendee.checkinMode`；
   *       系列级覆盖 `MeetingSeriesAttendeePreference` 保留。
   */
  async updateSeriesEnforceCheckinMode(
    seriesId: string,
    enforceCheckinMode: boolean,
    request?: Request,
    actor?: { userId?: string; id?: string; email?: string },
  ) {
    const series = await this.prisma.meetingSeries.findUnique({
      where: { id: seriesId },
      select: { id: true, city: true, enforceCheckinMode: true } as any,
    });
    if (!series) {
      throw new MeetingAttendanceError(404, 'Meeting series not found');
    }
    if (enforceCheckinMode && !(series as any).city) {
      throw new MeetingAttendanceError(400, '开启签到方式校验前请先配置会议地点', 'MEETING_ATTENDANCE_036');
    }

    const wasOn = Boolean((series as any).enforceCheckinMode);
    const isTurningOff = wasOn && !enforceCheckinMode;

    // 单事务保证：series 开关 + 下属 meeting 级联开关 + meeting-level checkinMode 清空全部原子，避免任一步失败留 stale
    const { updated, cascadeResult, clearedAttendeeOverrides } = await this.prisma.$transaction(async (tx) => {
      const next = await (tx.meetingSeries as any).update({
        where: { id: seriesId },
        data: { enforceCheckinMode },
        select: { id: true, enforceCheckinMode: true },
      });
      const cascade = await tx.meeting.updateMany({
        where: { seriesId },
        data: { enforceCheckinMode } as any,
      });
      let cleared: number | undefined;
      if (isTurningOff) {
        const r = await tx.meetingRequiredAttendee.updateMany({
          where: { meeting: { seriesId }, checkinMode: { not: null } as any },
          data: { checkinMode: null } as any,
        });
        cleared = r.count;
      }
      return { updated: next, cascadeResult: cascade, clearedAttendeeOverrides: cleared };
    });

    await this.auditLogWriter.log({
      request,
      actor,
      action: MEETING_ATTENDANCE_AUDIT_ACTIONS.SERIES_ENFORCE_CHECKIN_MODE_UPDATE,
      resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.SERIES,
      statusCode: 200,
      resourceId: seriesId,
      requestBody: { enforceCheckinMode },
      changes: {
        action: 'update_series_enforce_checkin_mode',
        before: wasOn,
        after: enforceCheckinMode,
        cascadeUpdatedMeetings: cascadeResult.count,
        ...(clearedAttendeeOverrides !== undefined ? { clearedAttendeeOverrides } : {}),
      },
    });

    return {
      seriesId: updated.id,
      enforceCheckinMode: updated.enforceCheckinMode,
      updatedMeetingCount: cascadeResult.count,
      ...(clearedAttendeeOverrides !== undefined ? { clearedAttendeeOverrides } : {}),
    };
  }

  /**
   * v1.2 查询系列的参会人签到方式默认覆盖列表
   * 参会人域 = 下属 meeting 的 requiredAttendees 聚合（去重）
   */
  async listSeriesAttendeePreferences(
    seriesId: string,
    query: { page?: number | string; pageSize?: number | string; keyword?: string },
  ) {
    const series = await this.prisma.meetingSeries.findUnique({
      where: { id: seriesId },
      select: { id: true },
    });
    if (!series) {
      throw new MeetingAttendanceError(404, 'Meeting series not found');
    }

    const keyword = (query.keyword || '').trim().toLowerCase();

    const attendees = await this.prisma.meetingRequiredAttendee.findMany({
      where: {
        meeting: { seriesId },
      },
      select: {
        userId: true,
        user: {
          select: { id: true, displayName: true, email: true },
        },
      },
      distinct: ['userId'],
    });

    const preferences = await (this.prisma as any).meetingSeriesAttendeePreference.findMany({
      where: { seriesId },
      select: {
        userId: true,
        defaultCheckinMode: true,
        updatedByUserId: true,
        updatedAt: true,
      },
    });
    const prefMap = new Map<string, any>(preferences.map((p: any) => [p.userId, p]));

    let items = attendees
      .filter((a) => !!a.user)
      .map((a) => {
        const pref = prefMap.get(a.userId);
        return {
          userId: a.userId,
          displayName: a.user!.displayName,
          email: a.user!.email,
          defaultCheckinMode: pref?.defaultCheckinMode ?? null,
          updatedByUserId: pref?.updatedByUserId ?? null,
          updatedAt: pref?.updatedAt ?? null,
        };
      });

    if (keyword) {
      items = items.filter(
        (x) => x.displayName.toLowerCase().includes(keyword) || x.email.toLowerCase().includes(keyword),
      );
    }

    const page = Math.max(1, Number(query.page) || 1);
    const pageSize = Math.max(1, Math.min(200, Number(query.pageSize) || 50));
    const total = items.length;
    const paginated = items.slice((page - 1) * pageSize, page * pageSize);

    return {
      items: paginated,
      pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
    };
  }

  /**
   * v1.2 批量设置系列级参会人签到方式默认覆盖（upsert）
   */
  async upsertSeriesAttendeePreferences(
    seriesId: string,
    preferences: Array<{ userId: string; defaultCheckinMode: 'ON_SITE' | 'ONLINE' }>,
    request?: Request,
    actor?: { userId?: string; id?: string; email?: string },
  ) {
    const series = await this.prisma.meetingSeries.findUnique({
      where: { id: seriesId },
      select: { id: true },
    });
    if (!series) {
      throw new MeetingAttendanceError(404, 'Meeting series not found');
    }

    const knownAttendeeRows = await this.prisma.meetingRequiredAttendee.findMany({
      where: { meeting: { seriesId } },
      select: { userId: true },
      distinct: ['userId'],
    });
    const knownUserIds = new Set<string>(knownAttendeeRows.map((r) => r.userId));

    const valid = preferences.filter((p) => knownUserIds.has(p.userId));
    const skippedUnknownUserIds = preferences
      .filter((p) => !knownUserIds.has(p.userId))
      .map((p) => p.userId);

    let created = 0;
    let updated = 0;
    const actorId = actor?.userId || actor?.id || null;

    for (const pref of valid) {
      const existing = await (this.prisma as any).meetingSeriesAttendeePreference.findUnique({
        where: { seriesId_userId: { seriesId, userId: pref.userId } },
      });
      if (existing) {
        await (this.prisma as any).meetingSeriesAttendeePreference.update({
          where: { seriesId_userId: { seriesId, userId: pref.userId } },
          data: { defaultCheckinMode: pref.defaultCheckinMode, updatedByUserId: actorId },
        });
        updated += 1;
      } else {
        await (this.prisma as any).meetingSeriesAttendeePreference.create({
          data: {
            seriesId,
            userId: pref.userId,
            defaultCheckinMode: pref.defaultCheckinMode,
            updatedByUserId: actorId,
          },
        });
        created += 1;
      }
    }

    await this.auditLogWriter.log({
      request,
      actor,
      action: MEETING_ATTENDANCE_AUDIT_ACTIONS.SERIES_ATTENDEE_PREFERENCE_UPSERT,
      resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.SERIES,
      statusCode: 200,
      resourceId: seriesId,
      requestBody: { preferences },
      changes: {
        action: 'series_attendee_preference_upsert',
        created,
        updated,
        skippedUnknownUserIds,
      },
    });

    return { seriesId, created, updated, skippedUnknownUserIds };
  }

  /**
   * v1.2 移除某参会人在系列下的签到方式默认覆盖
   */
  async deleteSeriesAttendeePreference(
    seriesId: string,
    userId: string,
    request?: Request,
    actor?: { userId?: string; id?: string; email?: string },
  ) {
    const existing = await (this.prisma as any).meetingSeriesAttendeePreference.findUnique({
      where: { seriesId_userId: { seriesId, userId } },
    });
    if (!existing) {
      return { seriesId, userId, removed: false };
    }

    await (this.prisma as any).meetingSeriesAttendeePreference.delete({
      where: { seriesId_userId: { seriesId, userId } },
    });

    await this.auditLogWriter.log({
      request,
      actor,
      action: MEETING_ATTENDANCE_AUDIT_ACTIONS.SERIES_ATTENDEE_PREFERENCE_DELETE,
      resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.SERIES,
      statusCode: 200,
      resourceId: seriesId,
      requestBody: { userId },
      changes: {
        action: 'series_attendee_preference_delete',
        before: existing.defaultCheckinMode,
      },
    });

    return { seriesId, userId, removed: true };
  }
}
