import { Injectable } from '@nestjs/common';
import { PrismaService } from '@core/database/prisma/prisma.service';
import { MeetingRepository } from '../repositories/meeting.repository';
import { RequiredAttendeeRepository } from '../repositories/required-attendee.repository';
import { MeetingAttendanceError } from '../errors/meeting-attendance.error';
import {
  deriveMeetingStatus,
  generateDualQRCodes,
  getMeetingAttendanceBaseUrl,
  normalizeName,
} from '../utils/meeting-utils';
import { mapUserWithPrimaryOrg } from '../utils/user-mapping';
import { Request } from 'express';
import { Prisma, AttendeeRole, OutlookBindingSyncMode } from '@prisma/client';
import {
  MEETING_ATTENDANCE_AUDIT_ACTIONS,
  MEETING_ATTENDANCE_AUDIT_RESOURCES,
} from '../constants/audit';
import { MeetingAttendanceAuditLogWriter } from './audit-log-writer.service';
import { MeetingPtoMarkingService } from './meeting-pto-marking.service';

const TENANT_DEFAULT_TIMEZONE = 'America/Los_Angeles';

@Injectable()
export class MeetingsService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly meetingRepository: MeetingRepository,
    private readonly requiredAttendeeRepository: RequiredAttendeeRepository,
    private readonly auditLogWriter: MeetingAttendanceAuditLogWriter,
    private readonly ptoMarking: MeetingPtoMarkingService,
  ) {}

  async listMeetings(query: { page?: number; limit?: number; status?: string; anchor?: string }) {
    const page = parseInt(String(query.page ?? 1), 10);
    const limit = parseInt(String(query.limit ?? 10), 10);
    const status = query.status ? String(query.status) : undefined;
    const anchor = query.anchor ? String(query.anchor) : undefined;

    const now = new Date();
    const where: Prisma.MeetingWhereInput = {};

    if (status === 'SCHEDULED') {
      where.startTime = { gte: now };
      where.status = { not: 'CANCELLED' };
    } else if (status === 'IN_PROGRESS') {
      where.startTime = { lte: now };
      where.endTime = { gte: now };
      where.status = { not: 'CANCELLED' };
    } else if (status === 'COMPLETED') {
      where.endTime = { lte: now };
      where.status = { not: 'CANCELLED' };
    }

    const orderBy = status === 'SCHEDULED' || status === 'all'
      ? ({ startTime: 'asc' } as const)
      : ({ startTime: 'desc' } as const);

    let resolvedPage = page;
    if (anchor === 'now' && (status === 'all' || status === 'SCHEDULED')) {
      const countWhere: Prisma.MeetingWhereInput = { ...where };
      if (where.startTime) {
        countWhere.startTime = {
          ...(where.startTime as Record<string, Date>),
          lte: now,
        };
      } else {
        countWhere.startTime = { lte: now };
      }

      const countBefore = await this.meetingRepository.countMeetings(countWhere);
      resolvedPage = Math.max(1, Math.floor(countBefore / limit) + 1);
    }

    const meetings = await this.meetingRepository.listMeetings(where, resolvedPage, limit, orderBy);
    const total = await this.meetingRepository.countMeetings(where);

    const updatePromises = meetings.map(async (meeting) => {
      const derivedStatus = deriveMeetingStatus(meeting.status, meeting.startTime, meeting.endTime, now);
      if (derivedStatus !== meeting.status) {
        try {
          await this.meetingRepository.updateMeetingStatus(meeting.id, derivedStatus);
          meeting.status = derivedStatus;
        } catch {
          return;
        }
      }
    });

    await Promise.all(updatePromises);

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

  async getMeetingDetails(id: string) {
    const [meeting, binding] = await Promise.all([
      this.meetingRepository.findMeetingWithDetails(id),
      this.prisma.outlookMeetingBinding.findFirst({
        where: { meetingId: id },
        include: {
          primaryMailbox: {
            select: {
              id: true,
              mailboxEmail: true,
            },
          },
        },
      }),
    ]);
    if (!meeting) {
      throw new MeetingAttendanceError(404, 'Meeting not found');
    }
    const internalAttendeeEmails = new Set(
      meeting.requiredAttendees
        .map((attendee) => attendee.user?.email?.trim().toLowerCase())
        .filter((email): email is string => Boolean(email)),
    );
    const externalAttendees = meeting.externalAttendees.filter((attendee) => {
      const email = attendee.email?.trim().toLowerCase();
      if (!email) {
        return true;
      }
      return !internalAttendeeEmails.has(email);
    });
    return {
      ...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),
      })),
      externalAttendees,
      outlookSync: binding ? {
        bindingId: binding.id,
        manageStatus: binding.manageStatus,
        syncMode: binding.syncMode,
        localOverrideAt: binding.localOverrideAt,
        localOverrideByUserId: binding.localOverrideByUserId,
        localOverrideByEmail: binding.localOverrideByEmail,
        localOverrideReason: binding.localOverrideReason,
        localOverrideFields: binding.localOverrideFields,
        primaryMailbox: binding.primaryMailbox,
      } : null,
    };
  }

  async createMeeting(
    payload: {
      title: string;
      description?: string;
      startTime: string;
      endTime: string;
      location?: string;
      type?: string;
      timezone?: string;
      city?: string;
      enforceCheckinMode?: boolean;
    },
    request: Request | undefined,
    userEmail: string,
    actor?: { userId?: string; id?: string; email?: string; displayName?: string; username?: string; roles?: any[] },
  ) {
    const user = await this.prisma.user.findUnique({ where: { email: userEmail, deletedAt: null } });
    if (!user) {
      throw new MeetingAttendanceError(404, 'User not found');
    }

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

    const start = new Date(payload.startTime);
    const end = new Date(payload.endTime);
    const now = new Date();

    let computedStatus: 'SCHEDULED' | 'IN_PROGRESS' | 'COMPLETED' = 'SCHEDULED';
    if (end < now) computedStatus = 'COMPLETED';
    else if (start <= now && end >= now) computedStatus = 'IN_PROGRESS';

    const meeting = await this.meetingRepository.createMeeting({
      title: payload.title,
      description: payload.description,
      startTime: start,
      endTime: end,
      timezone: payload.timezone || TENANT_DEFAULT_TIMEZONE,
      location: payload.location,
      type: (payload.type as any) || 'OFFLINE',
      status: computedStatus,
      creator: { connect: { id: user.id } },
      city: normalizedCity,
      enforceCheckinMode,
    });

    const baseUrl = getMeetingAttendanceBaseUrl(request);
    const qrCodes = await generateDualQRCodes(meeting.id, baseUrl);

    const updatedMeeting = await this.meetingRepository.updateMeeting(meeting.id, {
      qrCodeOnline: qrCodes.online,
      qrCodeOffline: qrCodes.offline,
    });

    await this.auditLogWriter.log({
      request,
      actor,
      action: MEETING_ATTENDANCE_AUDIT_ACTIONS.MEETING_CREATE,
      resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.MEETING,
      statusCode: 201,
      resourceId: meeting.id,
      requestBody: {
        title: payload.title,
        description: payload.description,
        startTime: payload.startTime,
        endTime: payload.endTime,
        location: payload.location,
        type: payload.type,
        timezone: payload.timezone,
      },
      changes: {
        action: 'create',
        newData: {
          id: meeting.id,
          title: meeting.title,
          startTime: meeting.startTime,
          endTime: meeting.endTime,
          location: meeting.location,
          type: meeting.type,
          status: meeting.status,
        },
      },
    });

    // 自动 PTO 标记（异步，不阻塞返回）
    this.ptoMarking.applyForMeetingFireAndForget(meeting.id);

    return updatedMeeting;
  }

  async updateMeeting(
    id: string,
    payload: {
      title?: string;
      description?: string;
      startTime?: string;
      endTime?: string;
      location?: string;
      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 originalMeeting = await this.meetingRepository.findMeetingById(id);
    if (!originalMeeting) {
      throw new MeetingAttendanceError(404, 'Meeting not found');
    }

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

    const start = payload.startTime ? new Date(payload.startTime) : originalMeeting.startTime;
    const end = payload.endTime ? new Date(payload.endTime) : originalMeeting.endTime;
    const now = new Date();

    let computedStatus = originalMeeting.status;
    let shouldMarkAbsent = false;

    if (originalMeeting.status !== 'CANCELLED') {
      if (end < now) {
        computedStatus = 'COMPLETED';
        shouldMarkAbsent = true;
      } else if (start <= now && end >= now) {
        computedStatus = 'IN_PROGRESS';
      } else {
        computedStatus = 'SCHEDULED';
      }
    }

    const updateData: any = {
      title: payload.title,
      description: payload.description,
      startTime: start,
      endTime: end,
      timezone: payload.timezone || TENANT_DEFAULT_TIMEZONE,
      location: payload.location,
      type: (payload.type as any) || originalMeeting.type,
      status: computedStatus,
    };
    if (payload.city !== undefined) updateData.city = nextCity;
    if (payload.enforceCheckinMode !== undefined) updateData.enforceCheckinMode = payload.enforceCheckinMode;

    const updatedMeeting = await this.meetingRepository.updateMeeting(id, updateData);

    if (shouldMarkAbsent) {
      await this.markAbsentForMeeting(id).catch(() => undefined);
    }

    await this.auditLogWriter.log({
      request,
      actor,
      action: MEETING_ATTENDANCE_AUDIT_ACTIONS.MEETING_UPDATE,
      resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.MEETING,
      statusCode: 200,
      resourceId: id,
      requestBody: payload as Record<string, unknown>,
      changes: {
        action: 'update',
        before: originalMeeting,
        after: updatedMeeting,
      },
    });

    // v1.2 city / enforceCheckinMode 是本地派生字段，不触发 Outlook 本地维护锁定
    const lockableFields = Object.keys(payload).filter(
      (key) =>
        payload[key as keyof typeof payload] !== undefined &&
        key !== 'city' &&
        key !== 'enforceCheckinMode',
    );
    if (lockableFields.length > 0) {
      await this.lockOutlookBindingForMeeting(id, actor, 'MANUAL_MEETING_EDIT', lockableFields);
    }

    // 时间字段变更时自动重跑 PTO 标记（startTime/endTime 影响重叠判断）
    if (payload.startTime !== undefined || payload.endTime !== undefined) {
      this.ptoMarking.applyForMeetingFireAndForget(id);
    }

    return updatedMeeting;
  }

  async deleteMeeting(
    id: string,
    request?: Request,
    actor?: { userId?: string; id?: string; email?: string; displayName?: string; username?: string; roles?: any[] },
  ) {
    const meeting = await this.prisma.meeting.findUnique({
      where: { id },
      include: { attendances: true },
    });

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

    if (meeting.attendances.length > 0) {
      await this.meetingRepository.updateMeetingStatus(id, 'CANCELLED');
      await this.auditLogWriter.log({
        request,
        actor,
        action: MEETING_ATTENDANCE_AUDIT_ACTIONS.MEETING_CANCEL,
        resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.MEETING,
        statusCode: 200,
        resourceId: id,
        changes: {
          action: 'cancel',
          meetingTitle: meeting.title,
          reason: 'Has attendance records',
        },
      });
      return {
        message: 'Meeting cancelled (cannot be permanently deleted due to attendance records)',
        type: 'soft_delete',
      };
    }

    await this.meetingRepository.deleteMeeting(id);
    await this.auditLogWriter.log({
      request,
      actor,
      action: MEETING_ATTENDANCE_AUDIT_ACTIONS.MEETING_DELETE,
      resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.MEETING,
      statusCode: 200,
      resourceId: id,
      changes: {
        action: 'delete',
        meetingTitle: meeting.title,
        deletedData: meeting,
      },
    });
    return {
      message: 'Meeting deleted successfully',
      type: 'hard_delete',
    };
  }

  async getPublicMeeting(id: string) {
    const meeting = await this.prisma.meeting.findUnique({
      where: { id },
      select: {
        id: true,
        title: true,
        description: true,
        startTime: true,
        endTime: true,
        location: true,
        type: true,
        status: true,
        timezone: true,
        creator: { select: { displayName: true } },
      },
    });

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

    const now = new Date();
    const startTime = new Date(meeting.startTime);
    const endTime = new Date(meeting.endTime);
    const checkinStartTime = new Date(startTime.getTime() - 15 * 60 * 1000);
    const checkinEndTime = new Date(endTime.getTime() + 15 * 60 * 1000);
    const canCheckin = now >= checkinStartTime && now <= checkinEndTime;

    return {
      ...meeting,
      canCheckin,
      checkinStartTime,
      checkinEndTime,
    };
  }

  async listRequiredAttendees(meetingId: string) {
    const [requiredAttendees, meeting] = await Promise.all([
      this.requiredAttendeeRepository.listRequiredAttendees(meetingId),
      this.prisma.meeting.findUnique({
        where: { id: meetingId },
        select: { id: true, seriesId: true, city: true, enforceCheckinMode: true } as any,
      }),
    ]);

    const meetingContext = meeting as
      | { id: string; seriesId?: string | null; city?: string | null; enforceCheckinMode?: boolean }
      | null;

    const seriesPrefs = meetingContext?.seriesId
      ? await (this.prisma as any).meetingSeriesAttendeePreference.findMany({
          where: {
            seriesId: meetingContext.seriesId,
            userId: { in: requiredAttendees.map((a) => a.userId) },
          },
          select: { userId: true, defaultCheckinMode: true },
        })
      : [];
    const seriesPrefByUserId = new Map<string, 'ON_SITE' | 'ONLINE'>();
    for (const p of seriesPrefs as Array<{ userId: string; defaultCheckinMode: 'ON_SITE' | 'ONLINE' }>) {
      seriesPrefByUserId.set(p.userId, p.defaultCheckinMode);
    }

    const meetingCity = meetingContext?.city?.trim() || null;

    const enriched = requiredAttendees.map((attendee: any) => {
      const userWorkCity = attendee.user?.workCity?.trim() || null;
      const meetingOverride = attendee.checkinMode ?? null;
      const seriesDefault = seriesPrefByUserId.get(attendee.userId) ?? null;

      let allowedMode: 'ON_SITE' | 'ONLINE' | null = null;
      let allowedModeSource: 'MEETING_OVERRIDE' | 'SERIES_PREFERENCE' | 'CITY_DERIVED' = 'CITY_DERIVED';

      if (meetingOverride) {
        allowedMode = meetingOverride;
        allowedModeSource = 'MEETING_OVERRIDE';
      } else if (seriesDefault) {
        allowedMode = seriesDefault;
        allowedModeSource = 'SERIES_PREFERENCE';
      } else if (!userWorkCity) {
        allowedMode = null;
        allowedModeSource = 'CITY_DERIVED';
      } else {
        allowedMode = userWorkCity === meetingCity ? 'ON_SITE' : 'ONLINE';
        allowedModeSource = 'CITY_DERIVED';
      }

      // Default = the layer that would apply if meetingOverride were cleared
      let defaultMode: 'ON_SITE' | 'ONLINE' | null = null;
      if (seriesDefault) {
        defaultMode = seriesDefault;
      } else if (userWorkCity) {
        defaultMode = userWorkCity === meetingCity ? 'ON_SITE' : 'ONLINE';
      }

      return {
        ...attendee,
        user: mapUserWithPrimaryOrg(attendee.user),
        workCity: userWorkCity,
        checkinMode: meetingOverride,
        allowedMode,
        allowedModeSource,
        defaultMode,
        isOverridden: meetingOverride !== null,
      };
    });

    return { requiredAttendees: enriched };
  }

  async addRequiredAttendees(
    meetingId: string,
    payload: {
      userIds?: string[];
      attendees?: Array<{
        userId?: string;
        role?: string;
      }>;
      markAsCustom?: boolean;
    },
    actor?: { userId?: string; id?: string; email?: string },
  ) {
    const meeting = await this.meetingRepository.findMeetingById(meetingId);
    if (!meeting) {
      throw new MeetingAttendanceError(404, 'Meeting not found');
    }

    const attendees: Array<{
      userId?: string;
      role?: string;
    }> = Array.isArray(payload.attendees)
      ? payload.attendees
      : Array.isArray(payload.userIds)
      ? payload.userIds.map((userId) => ({ userId, role: 'CORE' }))
      : [];

    if (attendees.length === 0) {
      throw new MeetingAttendanceError(400, 'Attendee list cannot be empty');
    }

    if (attendees.some((attendee) => !attendee.userId)) {
      throw new MeetingAttendanceError(400, 'Attendee userId is required');
    }

    const userIds = attendees.map((attendee) => attendee.userId).filter(Boolean) as string[];
    const existingAttendees = userIds.length
      ? await this.requiredAttendeeRepository.findRequiredAttendeesByUsers(meetingId, userIds)
      : [];
    const existingUserIds = new Set(existingAttendees.map((attendee) => attendee.userId));

    const createData = attendees
      .filter((attendee) => attendee.userId && !existingUserIds.has(attendee.userId))
      .map((attendee) => ({
        meetingId,
        userId: attendee.userId!,
        role: this.normalizeAttendeeRole(attendee.role),
      }));

    if (createData.length > 0) {
      await this.requiredAttendeeRepository.createRequiredAttendees(createData);

      const attendanceData = createData
        .filter((attendee) => attendee.userId)
        .map((attendee) => ({
          userId: attendee.userId!,
          meetingId,
          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 });
      }
    }

    if (payload.markAsCustom) {
      await this.meetingRepository.updateMeeting(meetingId, { hasCustomAttendees: true });
      await this.lockOutlookBindingForMeeting(meetingId, actor, 'MANUAL_ATTENDEE_ADD', ['requiredAttendees']);
    }

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

  async removeRequiredAttendee(
    meetingId: string,
    userId: string,
    actor?: { userId?: string; id?: string; email?: string },
  ) {
    const meeting = await this.meetingRepository.findMeetingById(meetingId);
    if (!meeting) {
      throw new MeetingAttendanceError(404, 'Meeting not found');
    }

    const attendee = await this.prisma.meetingRequiredAttendee.findUnique({
      where: {
        meetingId_userId: {
          meetingId,
          userId,
        },
      },
    });
    if (!attendee) {
      throw new MeetingAttendanceError(404, 'Meeting attendee not found');
    }

    const attendance = await this.prisma.meetingAttendance.findUnique({
      where: {
        userId_meetingId: {
          userId,
          meetingId,
        },
      },
    });
    const hasRealAttendanceRecord = Boolean(
      attendance && (
        attendance.checkinTime
        || attendance.status !== 'NOT_CHECKED_IN'
      ),
    );
    if (hasRealAttendanceRecord) {
      throw new MeetingAttendanceError(409, 'Attendee already has attendance record and cannot be removed');
    }

    await this.prisma.$transaction(async (tx) => {
      await tx.meetingRequiredAttendee.delete({
        where: {
          meetingId_userId: {
            meetingId,
            userId,
          },
        },
      });
      await tx.meetingAttendance.deleteMany({
        where: {
          meetingId,
          userId,
          checkinTime: null,
          status: 'NOT_CHECKED_IN',
        },
      });
    });

    await this.lockOutlookBindingForMeeting(meetingId, actor, 'MANUAL_ATTENDEE_DELETE', ['requiredAttendees']);
    const remainingAttendees = await this.requiredAttendeeRepository.countRequiredAttendees(meetingId);

    return {
      message: 'Attendee deleted successfully',
      remainingCount: remainingAttendees,
      syncMode: OutlookBindingSyncMode.LOCKED_BY_LOCAL_EDIT,
    };
  }

  async importAttendees(
    meetingId: string,
    attendees: any[],
    request?: Request,
    actor?: { userId?: string; id?: string; email?: string; displayName?: string; username?: string; roles?: any[] },
  ) {
    const meeting = await this.meetingRepository.findMeetingById(meetingId);
    if (!meeting) {
      throw new MeetingAttendanceError(404, 'Meeting not found');
    }

    if (!Array.isArray(attendees) || attendees.length === 0) {
      throw new MeetingAttendanceError(400, 'Attendees data cannot be empty');
    }

    const results = {
      created: 0,
      updated: 0,
      errors: [] as string[],
    };

    for (const attendee of attendees) {
      try {
        const { userId, email, role } = attendee;

        let resolvedUserId = userId as string | undefined;
        if (!resolvedUserId && email && String(email).trim()) {
          const trimmedEmail = String(email).trim().toLowerCase();
          const user = await this.prisma.user.findUnique({ where: { email: trimmedEmail, deletedAt: null } });
          if (user) {
            resolvedUserId = user.id;
          }
        }

        if (!resolvedUserId) {
          results.errors.push(`User not found: ${email || JSON.stringify(attendee)}`);
          continue;
        }

        await this.prisma.meetingRequiredAttendee
          .create({
            data: {
              meetingId,
              userId: resolvedUserId,
              role: this.normalizeAttendeeRole(role || 'OPTIONAL_ATTENDEE'),
            },
          })
          .catch(() => undefined);
        results.created += 1;
      } catch (error) {
        const errorMessage = error instanceof Error ? error.message : 'Unknown error';
        results.errors.push(`Failed to process: ${attendee.email || attendee.userId} - ${errorMessage}`);
      }
    }

    if (results.created > 0) {
      await this.meetingRepository.updateMeeting(meetingId, { hasCustomAttendees: true });
      await this.lockOutlookBindingForMeeting(meetingId, actor, 'MANUAL_ATTENDEE_IMPORT', ['requiredAttendees']);
    }

    await this.auditLogWriter.log({
      request,
      actor,
      action: MEETING_ATTENDANCE_AUDIT_ACTIONS.ATTENDANCE_IMPORT,
      resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.ATTENDANCE,
      statusCode: 200,
      resourceId: meetingId,
      requestBody: { totalAttendees: attendees.length },
      changes: {
        action: 'bulk_import_attendees',
        meetingId,
        meetingTitle: meeting.title,
        createdUsers: results.created,
        updatedUsers: results.updated,
        errorCount: results.errors.length,
        errors: results.errors.slice(0, 10),
      },
    });

    return {
      message: 'Import completed',
      results,
    };
  }

  async markAbsentForMeeting(meetingId: string) {
    const meeting = await this.prisma.meeting.findUnique({
      where: { id: meetingId },
      include: {
        requiredAttendees: { include: { user: true } },
        attendances: true,
      },
    });

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

    if (meeting.status !== 'COMPLETED') {
      throw new MeetingAttendanceError(400, 'Meeting must be completed to mark attendees as absent');
    }

    const attendedUserIds = new Set(meeting.attendances.map((attendance) => attendance.userId));
    const unmarkedAttendees = meeting.requiredAttendees.filter(
      (attendee) => attendee.user && !attendedUserIds.has(attendee.user.id),
    );

    const absentRecords = await Promise.all(
      unmarkedAttendees.map((attendee) =>
        this.prisma.meetingAttendance.create({
          data: {
            userId: attendee.user!.id,
            meetingId,
            status: 'ABSENT',
            checkinTime: null,
            isLate: false,
            checkinType: null,
            notes: 'Marked as absent after meeting completion',
          },
        }),
      ),
    );

    return {
      message: `Marked ${absentRecords.length} attendees as absent`,
      count: absentRecords.length,
    };
  }

  async searchAttendees(meetingId: string, query: string | null) {
    if (!query || query.trim().length < 2) {
      return { attendees: [] };
    }

    const meeting = await this.meetingRepository.findMeetingById(meetingId);
    if (!meeting) {
      throw new MeetingAttendanceError(404, 'Meeting not found');
    }

    const allAttendees = await this.requiredAttendeeRepository.findAttendeeForMeeting(meetingId);
    const normalizedQuery = normalizeName(query);
    const lowercaseQuery = query.toLowerCase();

    const filteredAttendees = allAttendees.filter((attendee) => {
      const name = attendee.user?.displayName;
      const email = attendee.user?.email;

      if (email && email.toLowerCase().includes(lowercaseQuery)) {
        return true;
      }

      if (name) {
        const normalizedName = normalizeName(name);
        if (normalizedName.includes(normalizedQuery)) {
          return true;
        }
        if (name.toLowerCase().includes(lowercaseQuery)) {
          return true;
        }
      }

      return false;
    });

    const attendees = filteredAttendees.slice(0, 10).map((attendee) => ({
      id: attendee.id,
      displayName: attendee.user?.displayName,
      email: attendee.user?.email,
      role: attendee.role,
    }));

    return { attendees };
  }

  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,
        syncMode: true,
        primaryMailboxId: true,
      },
    });
    if (!binding) {
      return null;
    }

    const lockedBinding = 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,
        },
      },
    });

    return lockedBinding;
  }

  /**
   * v1.2 切换单场会议的"签到方式校验"开关。
   * 独立 endpoint，不触发 Outlook 本地维护锁定。
   * 开启前 guard：必须已配置 `city`。
   * v1.4：ON→OFF 时清空本场所有 `MeetingRequiredAttendee.checkinMode`（meeting-level 临时覆盖）；
   *       系列级覆盖 `MeetingSeriesAttendeePreference` 保留，便于重开后自动恢复。
   */
  async updateMeetingEnforceCheckinMode(
    meetingId: string,
    enforceCheckinMode: boolean,
    request?: Request,
    actor?: { userId?: string; id?: string; email?: string },
  ) {
    const meeting = await this.prisma.meeting.findUnique({
      where: { id: meetingId },
      select: { id: true, city: true, enforceCheckinMode: true } as any,
    });
    if (!meeting) {
      throw new MeetingAttendanceError(404, 'Meeting not found');
    }
    if (enforceCheckinMode && !(meeting as any).city) {
      throw new MeetingAttendanceError(400, '开启签到方式校验前请先配置会议地点', 'MEETING_ATTENDANCE_036');
    }

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

    // 单事务保证：开关翻转 + 清空 meeting-level checkinMode 同生同灭，避免半成功留 stale 覆盖
    const { updated, clearedAttendeeOverrides } = await this.prisma.$transaction(async (tx) => {
      const next = await (tx.meeting as any).update({
        where: { id: meetingId },
        data: { enforceCheckinMode },
        select: { id: true, enforceCheckinMode: true, city: true },
      });
      let cleared: number | undefined;
      if (isTurningOff) {
        const r = await tx.meetingRequiredAttendee.updateMany({
          where: { meetingId, checkinMode: { not: null } as any },
          data: { checkinMode: null } as any,
        });
        cleared = r.count;
      }
      return { updated: next, clearedAttendeeOverrides: cleared };
    });

    await this.auditLogWriter.log({
      request,
      actor,
      action: MEETING_ATTENDANCE_AUDIT_ACTIONS.MEETING_ENFORCE_CHECKIN_MODE_UPDATE,
      resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.MEETING,
      statusCode: 200,
      resourceId: meetingId,
      requestBody: { enforceCheckinMode },
      changes: {
        action: 'update_enforce_checkin_mode',
        before: wasOn,
        after: enforceCheckinMode,
        ...(clearedAttendeeOverrides !== undefined ? { clearedAttendeeOverrides } : {}),
      },
    });

    return {
      meetingId: updated.id,
      enforceCheckinMode: updated.enforceCheckinMode,
      ...(clearedAttendeeOverrides !== undefined ? { clearedAttendeeOverrides } : {}),
    };
  }

  /**
   * v1.2 会议级单参会人签到方式临时调整。
   * 独立 endpoint，不触发 Outlook 本地维护锁定。
   * 传 null 清除覆盖，恢复到系列级 / 城市派生。
   */
  async updateRequiredAttendeeCheckinMode(
    meetingId: string,
    userId: string,
    checkinMode: 'ON_SITE' | 'ONLINE' | null | undefined,
    request?: Request,
    actor?: { userId?: string; id?: string; email?: string },
  ) {
    const attendee = await this.prisma.meetingRequiredAttendee.findUnique({
      where: { meetingId_userId: { meetingId, userId } },
      select: { id: true, checkinMode: true } as any,
    });
    if (!attendee) {
      throw new MeetingAttendanceError(404, 'Meeting attendee not found', 'MEETING_ATTENDANCE_031');
    }

    const previous = (attendee as any).checkinMode ?? null;
    const next = checkinMode ?? null;

    const updated = await (this.prisma.meetingRequiredAttendee as any).update({
      where: { meetingId_userId: { meetingId, userId } },
      data: { checkinMode: next },
      select: { id: true, checkinMode: true },
    });

    await this.auditLogWriter.log({
      request,
      actor,
      action: MEETING_ATTENDANCE_AUDIT_ACTIONS.ATTENDEE_CHECKIN_MODE_OVERRIDE,
      resource: MEETING_ATTENDANCE_AUDIT_RESOURCES.ATTENDANCE,
      statusCode: 200,
      resourceId: updated.id,
      requestBody: { meetingId, userId, checkinMode: next },
      changes: {
        action: 'attendee_checkin_mode_override',
        before: previous,
        after: next,
      },
    });

    return {
      meetingId,
      userId,
      checkinMode: updated.checkinMode,
      previousCheckinMode: previous,
    };
  }

}
