import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '@core/database/prisma/prisma.service';
import { createHash } from 'crypto';
import { ClientSecretCredential } from '@azure/identity';
import { Client } from '@microsoft/microsoft-graph-client';
import { TokenCredentialAuthenticationProvider } from '@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials';
import {
  AttendeeRole,
  MeetingStatus,
  Prisma,
  OutlookBootstrapStatus,
  OutlookBindingSyncMode,
  OutlookCancellationSource,
  OutlookMailboxType,
  OutlookManageStatus,
  OutlookSubscriptionStatus,
  RecurrencePattern,
} from '@prisma/client';
import { createLogger } from '@core/observability/logging/config/winston.config';
import { OutlookSyncRepository } from '../repositories/outlook-sync.repository';
import { OutlookAttendeeAutoAddService } from './outlook-attendee-auto-add.service';
import { MeetingAttendanceError } from '../errors/meeting-attendance.error';
import {
  ExcludeOutlookSeriesOccurrenceDto,
  ListOutlookCandidateChildrenQueryDto,
  ListManagedSeriesChildrenQueryDto,
  ListOutlookSeriesOccurrenceExclusionsQueryDto,
  ListOutlookBindingHistoryQueryDto,
  ListManagedOutlookBindingsQueryDto,
  ListAllManagedOutlookBindingsQueryDto,
  ListOutlookCandidatesQueryDto,
  ManageOutlookBindingDto,
  TakeoverOutlookBindingDto,
  UpdateOutlookSyncSettingsDto,
} from '../dto/outlook-sync.dto';
import { generateDualQRCodes, getMeetingAttendanceBaseUrl } from '../utils/meeting-utils';
import { convertToUTC, normalizeToIanaTimezone } from '../utils/timezone-utils';

const logger = createLogger('MeetingAttendanceOutlookSyncService');
const DEFAULT_OUTLOOK_SYNC_TIMEZONE = 'America/Los_Angeles';
export const STRICT_SERIES_FILTER_FRESHNESS_MS = 10 * 60 * 1000;
const INITIAL_SNAPSHOT_BOOTSTRAP_FETCH_TOP = 800;
const STALE_CURSOR_SERIES_SYNC_ENQUEUE_INTERVAL_MS = 60 * 1000;
const RECONCILE_SERIES_SNAPSHOT_SCAN_LIMIT = 100;
export const RECONCILE_SERIES_SNAPSHOT_BACKFILL_LIMIT = 20;

export function shouldUseStrictSeriesMasterFilter(
  lastSyncedAt: Date | null | undefined,
  now: Date,
): boolean {
  if (!lastSyncedAt) {
    return false;
  }
  return now.getTime() - lastSyncedAt.getTime() <= STRICT_SERIES_FILTER_FRESHNESS_MS;
}

type GraphDateTimeInfo = {
  dateTime?: string;
  timeZone?: string;
};

type GraphAttendeeInfo = {
  type?: string;
  status?: {
    response?: string;
  };
  emailAddress?: {
    name?: string;
    address?: string;
  };
};

type GraphEventInfo = {
  id: string;
  iCalUId?: string;
  subject?: string;
  bodyPreview?: string;
  start?: GraphDateTimeInfo;
  end?: GraphDateTimeInfo;
  location?: { displayName?: string };
  isCancelled?: boolean;
  type?: string;
  seriesMasterId?: string;
  recurrence?: {
    pattern?: {
      type?: string;
      interval?: number;
    };
    range?: {
      type?: string;
      startDate?: string;
      endDate?: string;
      numberOfOccurrences?: number;
      recurrenceTimeZone?: string;
    };
  };
  attendees?: GraphAttendeeInfo[];
  organizer?: {
    emailAddress?: {
      address?: string;
      name?: string;
    };
  };
  lastModifiedDateTime?: string;
  webLink?: string;
  originalStartTimeZone?: string;
  originalEndTimeZone?: string;
  '@removed'?: {
    reason?: string;
  };
};

@Injectable()
export class OutlookSyncService {
  private graphClient: Client | null = null;
  private readonly mailboxSyncQueue = new Map<string, { running: boolean; pending: boolean; pendingReconcile: boolean }>();
  private readonly seriesBootstrapQueue = new Map<string, Promise<void>>();
  private readonly mailboxInitialSnapshotBootstrapQueue = new Map<string, Promise<void>>();
  private readonly mailboxSeriesSyncEnqueueAt = new Map<string, number>();

  constructor(
    private readonly configService: ConfigService,
    private readonly prisma: PrismaService,
    private readonly outlookSyncRepository: OutlookSyncRepository,
    private readonly attendeeAutoAddService: OutlookAttendeeAutoAddService,
  ) {}

  private async updateMailboxStatus(id: string, isEnabled: boolean) {
    const mailbox = await this.outlookSyncRepository.findMailboxById(id);
    if (!mailbox) {
      throw new MeetingAttendanceError(404, 'Source mailbox not found');
    }

    if (isEnabled) {
      const latest = await this.outlookSyncRepository.getLatestActiveSubscription(id);
      if ((!latest || latest.expirationAt <= new Date()) && this.canCreateWebhookSubscription()) {
        const subscription = await this.createGraphSubscription(mailbox.mailboxEmail);
        await this.outlookSyncRepository.createSubscription({
          mailbox: { connect: { id: mailbox.id } },
          graphSubscriptionId: subscription.id,
          resource: subscription.resource,
          status: OutlookSubscriptionStatus.ACTIVE,
          expirationAt: new Date(subscription.expirationDateTime),
        });
      } else if ((!latest || latest.expirationAt <= new Date()) && !this.canCreateWebhookSubscription()) {
        logger.warn(
          `Skip Graph subscription enable for mailbox ${mailbox.mailboxEmail}: webhook url is not public HTTPS`,
        );
      }
      await this.outlookSyncRepository.updateMailboxStatus(id, true);
    } else {
      const latest = await this.outlookSyncRepository.getLatestActiveSubscription(id);
      if (latest) {
        await this.deleteGraphSubscription(latest.graphSubscriptionId).catch((error) => {
          logger.warn(`Failed to delete Graph subscription ${latest.graphSubscriptionId}: ${String(error)}`);
        });
      }
      await this.outlookSyncRepository.markMailboxSubscriptionsDisabled(id);
      await this.outlookSyncRepository.updateMailboxStatus(id, false);
    }

    return this.outlookSyncRepository.findMailboxById(id);
  }

  async getSettings() {
    return this.outlookSyncRepository.getOrCreateSettings();
  }

  async updateSettings(payload: UpdateOutlookSyncSettingsDto) {
    await this.outlookSyncRepository.getOrCreateSettings();
    return this.outlookSyncRepository.updateSettings({
      reconcileCron: payload.reconcileCron,
      deltaBatchSize: payload.deltaBatchSize,
      lookaheadDays: payload.lookaheadDays,
      lookbackDays: payload.lookbackDays,
      renewBeforeMinutes: payload.renewBeforeMinutes,
      includeOrganizerAsAttendee: payload.includeOrganizerAsAttendee,
    });
  }

  async listCandidates(query: ListOutlookCandidatesQueryDto, actor: { id: string; email: string }) {
    const settings = await this.outlookSyncRepository.getOrCreateSettings();
    const page = query.page ?? 1;
    const pageSize = query.pageSize ?? 20;
    const includeCancelled = this.parseBooleanLike(query.includeCancelled);
    const includePast = this.parseBooleanLike(query.includePast);
    const onlyUnmanaged = this.parseBooleanLike(query.onlyUnmanaged);
    const mailbox = await this.resolveCandidateMailbox(query.mailboxId, actor.email);
    if (!mailbox) {
      return {
        mailbox: null,
        items: [],
        pagination: {
          page,
          pageSize,
          total: 0,
          totalPages: 0,
        },
      };
    }
    const cursor = await this.outlookSyncRepository.getCursorByMailboxId(mailbox.id);
    if (!cursor) {
      this.enqueueInitialSnapshotBootstrap(mailbox.id, mailbox.mailboxEmail);
    }

    const startDate = query.startDate
      ? new Date(query.startDate)
      : new Date(Date.now() - settings.lookbackDays * 24 * 60 * 60 * 1000);
    const endDate = query.endDate
      ? new Date(query.endDate)
      : new Date(Date.now() + settings.lookaheadDays * 24 * 60 * 60 * 1000);

    const normalizedEventType = query.eventType?.trim();
    if (!normalizedEventType || normalizedEventType === 'seriesMaster') {
      await this.backfillSeriesMasterSnapshotsIfMissing(mailbox.id, mailbox.mailboxEmail);
    }

    const snapshotResult = await this.loadCandidateSnapshots({
      mailboxId: mailbox.id,
      actorId: actor.id,
      startDate,
      endDate,
      keyword: query.keyword?.trim() || undefined,
      eventType: normalizedEventType || undefined,
      includeCancelled,
      includePast,
      onlyUnmanaged,
      page,
      pageSize,
    });
    const transformed: any[] = snapshotResult.items;
    const total = snapshotResult.total;

    if (snapshotResult.total === 0) {
      // 候选页不做实时 Graph 回退，避免接口阻塞；由后台队列异步准备快照数据。
      const initializing = !cursor
        && this.mailboxInitialSnapshotBootstrapQueue.has(mailbox.id);
      if (!initializing) {
        this.enqueueMailboxSync(mailbox.id, true);
      }
    }

    const seriesMasterIds = transformed
      .filter((item) => item.eventType === 'seriesMaster')
      .map((item) => item.graphEventId);
    const seriesChildCountMap = await this.getSeriesChildCountMap({
      mailboxId: mailbox.id,
      seriesMasterIds,
      includeCancelled,
      includePast,
    });
    const items = transformed.map((item) => (
      item.eventType === 'seriesMaster'
        ? { ...item, seriesChildCount: seriesChildCountMap.get(item.graphEventId) || 0 }
        : item
    ));

    return {
      mailbox: {
        id: mailbox.id,
        mailboxEmail: mailbox.mailboxEmail,
        mailboxType: mailbox.mailboxType,
      },
      items,
      snapshotInitializing: !cursor?.lastSyncedAt
        || this.mailboxInitialSnapshotBootstrapQueue.has(mailbox.id),
      sourceLagSeconds: cursor?.lastSyncedAt
        ? Math.max(0, Math.floor((Date.now() - cursor.lastSyncedAt.getTime()) / 1000))
        : null,
      pagination: {
        page,
        pageSize,
        total,
        totalPages: Math.max(1, Math.ceil(total / pageSize)),
      },
    };
  }

  private enqueueInitialSnapshotBootstrap(mailboxId: string, mailboxEmail: string) {
    if (this.mailboxInitialSnapshotBootstrapQueue.has(mailboxId)) {
      return;
    }
    const task = (async () => {
      try {
        const settings = await this.outlookSyncRepository.getOrCreateSettings();
        const startDate = new Date(Date.now() - settings.lookbackDays * 24 * 60 * 60 * 1000);
        const endDate = new Date(Date.now() + settings.lookaheadDays * 24 * 60 * 60 * 1000);
        const events = await this.fetchCalendarViewEvents(
          mailboxEmail,
          startDate,
          endDate,
          INITIAL_SNAPSHOT_BOOTSTRAP_FETCH_TOP,
        );
        if (events.length > 0) {
          await Promise.all(events.map((event) => this.upsertEventSnapshot(mailboxId, event)));
        }
        this.enqueueMailboxSync(mailboxId, true);
      } catch (error) {
        logger.warn(`Initial snapshot bootstrap failed for ${mailboxEmail}: ${String(error)}`);
      } finally {
        this.mailboxInitialSnapshotBootstrapQueue.delete(mailboxId);
      }
    })();
    this.mailboxInitialSnapshotBootstrapQueue.set(mailboxId, task);
  }

  private async backfillSeriesMasterSnapshotsIfMissing(mailboxId: string, mailboxEmail: string) {
    const seriesMasterCount = await this.prisma.outlookEventSnapshot.count({
      where: {
        mailboxId,
        eventType: 'seriesMaster',
      },
    });
    if (seriesMasterCount > 0) {
      return;
    }

    try {
      const seriesMasters = await this.fetchSeriesMasterEvents(mailboxEmail, 200);
      if (seriesMasters.length === 0) {
        return;
      }
      await Promise.all(seriesMasters.map((event) => this.upsertEventSnapshot(mailboxId, event)));
    } catch (error) {
      logger.warn(`Backfill series master snapshots failed for ${mailboxEmail}: ${String(error)}`);
    }
  }

  private async backfillCandidateSeriesChildrenSnapshotsForReconcile(
    mailbox: { id: string; mailboxEmail: string },
    now: Date,
  ) {
    const settings = await this.outlookSyncRepository.getOrCreateSettings();
    const startDate = new Date(Date.now() - settings.lookbackDays * 24 * 60 * 60 * 1000);
    const endDate = new Date(Date.now() + settings.lookaheadDays * 24 * 60 * 60 * 1000);

    const candidateSeriesMasters = await this.prisma.outlookEventSnapshot.findMany({
      where: {
        mailboxId: mailbox.id,
        eventType: 'seriesMaster',
        isCancelled: false,
      },
      orderBy: [{ lastModifiedAt: 'desc' }, { updatedAt: 'desc' }],
      take: RECONCILE_SERIES_SNAPSHOT_SCAN_LIMIT,
      select: {
        graphEventId: true,
        rawPayload: true,
      },
    });

    if (candidateSeriesMasters.length === 0) {
      return;
    }

    const activeSeriesMasterIds = candidateSeriesMasters
      .filter((item) => !this.isSeriesMasterEnded(this.getGraphEventFromRawPayload(item.rawPayload), now))
      .map((item) => item.graphEventId)
      .filter((value): value is string => Boolean(value));

    if (activeSeriesMasterIds.length === 0) {
      return;
    }

    const existingSeriesChildren = await this.prisma.outlookEventSnapshot.groupBy({
      by: ['seriesMasterId'],
      where: {
        mailboxId: mailbox.id,
        eventType: { in: ['occurrence', 'exception'] },
        isCancelled: false,
        startTime: { gte: now, lte: endDate },
        seriesMasterId: { in: activeSeriesMasterIds },
      },
    });

    const existingSeriesChildIds = new Set(
      existingSeriesChildren
        .map((item) => item.seriesMasterId)
        .filter((value): value is string => Boolean(value)),
    );

    const targetSeriesMasterIds = activeSeriesMasterIds
      .filter((graphEventId) => !existingSeriesChildIds.has(graphEventId))
      .slice(0, RECONCILE_SERIES_SNAPSHOT_BACKFILL_LIMIT);

    for (const seriesMasterId of targetSeriesMasterIds) {
      try {
        const instanceEvents = await this.fetchSeriesInstanceEvents(
          mailbox.mailboxEmail,
          seriesMasterId,
          startDate,
          endDate,
        );
        const targetEvents = instanceEvents.filter(
          (item) =>
            item.id
            && ['occurrence', 'exception'].includes(this.getNormalizedEventType(item))
            && item.seriesMasterId === seriesMasterId,
        );
        if (targetEvents.length === 0) {
          continue;
        }
        await Promise.all(targetEvents.map((event) => this.upsertEventSnapshot(mailbox.id, event)));
      } catch (error) {
        logger.warn(
          `Reconcile series snapshot backfill failed for ${mailbox.mailboxEmail}/${seriesMasterId}: ${String(error)}`,
        );
      }
    }
  }

  async listCandidateSeriesChildren(
    seriesMasterId: string,
    query: ListOutlookCandidateChildrenQueryDto,
    actor: { id: string; email: string },
  ) {
    const mailbox = await this.resolveCandidateMailbox(query.mailboxId, actor.email);
    if (!mailbox) {
      return {
        mailbox: null,
        seriesMasterId,
        items: [],
      };
    }

    const includeCancelled = this.parseBooleanLike(query.includeCancelled);
    const includePast = this.parseBooleanLike(query.includePast);
    const now = new Date();
    if (!includePast) {
      const seriesMasterEvent = await this.getCandidateSeriesMasterEvent(mailbox.id, mailbox.mailboxEmail, seriesMasterId);
      if (seriesMasterEvent && this.isSeriesMasterEnded(seriesMasterEvent, now)) {
        return {
          mailbox: {
            id: mailbox.id,
            mailboxEmail: mailbox.mailboxEmail,
            mailboxType: mailbox.mailboxType,
          },
          seriesMasterId,
          items: [],
        };
      }
    }
    const [snapshotItems, cursor] = await Promise.all([
      this.loadCandidateSeriesChildrenFromSnapshots({
        mailboxId: mailbox.id,
        seriesMasterId,
        includeCancelled,
        includePast,
        actorId: actor.id,
      }),
      this.outlookSyncRepository.getCursorByMailboxId(mailbox.id),
    ]);
    const hasSnapshotChildren = snapshotItems.length > 0
      || await this.hasSeriesChildrenSnapshots(mailbox.id, seriesMasterId);
    if (hasSnapshotChildren) {
      if (!shouldUseStrictSeriesMasterFilter(cursor?.lastSyncedAt, now)) {
        this.enqueueMailboxSyncIfStaleSeries(cursor?.lastSyncedAt, mailbox.id, now);
      }
      return {
        mailbox: {
          id: mailbox.id,
          mailboxEmail: mailbox.mailboxEmail,
          mailboxType: mailbox.mailboxType,
        },
        seriesMasterId,
        items: snapshotItems,
      };
    }

    const settings = await this.outlookSyncRepository.getOrCreateSettings();
    const startDate = new Date(Date.now() - settings.lookbackDays * 24 * 60 * 60 * 1000);
    const endDate = new Date(Date.now() + settings.lookaheadDays * 24 * 60 * 60 * 1000);
    const [instanceEvents, managedSeriesBindings] = await Promise.all([
      this.fetchSeriesInstanceEvents(mailbox.mailboxEmail, seriesMasterId, startDate, endDate),
      this.outlookSyncRepository.listManagedSeriesBindingsByMailbox(mailbox.id),
    ]);
    if (instanceEvents.length > 0) {
      await Promise.all(instanceEvents.map((event) => this.upsertEventSnapshot(mailbox.id, event)));
    }
    const graphEventIds = Array.from(
      new Set(instanceEvents.map((event) => event.id).filter((value): value is string => Boolean(value))),
    );
    const existedBindings = graphEventIds.length > 0
      ? await this.outlookSyncRepository.findBindingsByGraphEventIds(graphEventIds)
      : [];
    const bindingMap = new Map(existedBindings.map((item) => [item.graphEventId, item]));
    const seriesBindingMap = new Map(managedSeriesBindings.map((item) => [item.graphEventId, item]));

    const items = instanceEvents
      .filter((event) => ['occurrence', 'exception'].includes(this.getNormalizedEventType(event)))
      .filter((event) => includeCancelled || !this.isEffectivelyCancelledEvent(event))
      .filter((event) => {
        if (includePast) {
          return true;
        }
        const startTime = this.parseGraphDateTimeSafe(event.start, event);
        if (!startTime) {
          return false;
        }
        return startTime.getTime() >= now.getTime();
      })
      .map((event) =>
        this.toCandidateItem(event, {
          bindingMap,
          seriesBindingMap,
          actorId: actor.id,
          fallbackMailboxId: mailbox.id,
        }))
      .sort((a, b) => this.parseGraphDateTimeToMs(a.startTime, a.timezone) - this.parseGraphDateTimeToMs(b.startTime, b.timezone));

    return {
      mailbox: {
        id: mailbox.id,
        mailboxEmail: mailbox.mailboxEmail,
        mailboxType: mailbox.mailboxType,
      },
      seriesMasterId,
      items,
    };
  }

  private async loadCandidateSeriesChildrenFromSnapshots(params: {
    mailboxId: string;
    seriesMasterId: string;
    includeCancelled: boolean;
    includePast: boolean;
    actorId: string;
  }) {
    const snapshots = await this.prisma.outlookEventSnapshot.findMany({
      where: {
        mailboxId: params.mailboxId,
        seriesMasterId: params.seriesMasterId,
        eventType: { in: ['occurrence', 'exception'] },
        ...(params.includeCancelled ? {} : { isCancelled: false }),
        ...(params.includePast ? {} : { startTime: { gte: new Date() } }),
      },
      orderBy: [{ startTime: 'asc' }, { updatedAt: 'desc' }],
    });
    if (snapshots.length === 0) {
      return [] as any[];
    }
    const graphEventIds = Array.from(
      new Set(snapshots.map((item) => item.graphEventId).filter((value): value is string => Boolean(value))),
    );
    const [existedBindings, managedSeriesBindings] = await Promise.all([
      graphEventIds.length > 0
        ? this.outlookSyncRepository.findBindingsByGraphEventIds(graphEventIds)
        : Promise.resolve([]),
      this.outlookSyncRepository.listManagedSeriesBindingsByMailbox(params.mailboxId),
    ]);
    const bindingMap = new Map(existedBindings.map((item) => [item.graphEventId, item]));
    const seriesBindingMap = new Map(managedSeriesBindings.map((item) => [item.graphEventId, item]));
    return snapshots
      .map((snapshot) => this.toCandidateItemFromSnapshot(snapshot, {
        bindingMap,
        seriesBindingMap,
        actorId: params.actorId,
        fallbackMailboxId: params.mailboxId,
      }))
      .sort((a, b) => this.parseGraphDateTimeToMs(a.startTime, a.timezone) - this.parseGraphDateTimeToMs(b.startTime, b.timezone));
  }

  private async hasSeriesChildrenSnapshots(mailboxId: string, seriesMasterId: string) {
    const count = await this.prisma.outlookEventSnapshot.count({
      where: {
        mailboxId,
        seriesMasterId,
        eventType: { in: ['occurrence', 'exception'] },
      },
    });
    return count > 0;
  }

  private async getCandidateSeriesMasterEvent(
    mailboxId: string,
    mailboxEmail: string,
    seriesMasterId: string,
  ) {
    const snapshot = await this.prisma.outlookEventSnapshot.findUnique({
      where: {
        mailboxId_graphEventId: {
          mailboxId,
          graphEventId: seriesMasterId,
        },
      },
      select: {
        rawPayload: true,
      },
    });
    const snapshotEvent = this.getGraphEventFromRawPayload(snapshot?.rawPayload);
    if (snapshotEvent) {
      return snapshotEvent;
    }
    return this.fetchEventById(mailboxEmail, seriesMasterId);
  }

  private getGraphEventFromRawPayload(rawPayload: Prisma.JsonValue | null | undefined): GraphEventInfo | null {
    if (!rawPayload || typeof rawPayload !== 'object' || Array.isArray(rawPayload)) {
      return null;
    }
    return rawPayload as unknown as GraphEventInfo;
  }

  private isSeriesMasterEnded(event: GraphEventInfo | null | undefined, now: Date): boolean {
    if (!event || this.getNormalizedEventType(event) !== 'seriesMaster') {
      return false;
    }
    const timezone = this.resolveEventTimezone(event);
    const { endDate } = this.resolveSeriesRange(event, timezone);
    if (endDate) {
      return endDate.getTime() < now.getTime();
    }
    const eventEnd = this.parseGraphDateTimeSafe(event.end, event);
    return Boolean(eventEnd && eventEnd.getTime() < now.getTime());
  }

  private enqueueMailboxSyncIfStaleSeries(
    lastSyncedAt: Date | null | undefined,
    mailboxId: string,
    now: Date,
  ) {
    if (shouldUseStrictSeriesMasterFilter(lastSyncedAt, now)) {
      return;
    }
    const lastEnqueuedAt = this.mailboxSeriesSyncEnqueueAt.get(mailboxId) || 0;
    if (now.getTime() - lastEnqueuedAt < STALE_CURSOR_SERIES_SYNC_ENQUEUE_INTERVAL_MS) {
      return;
    }
    this.mailboxSeriesSyncEnqueueAt.set(mailboxId, now.getTime());
    this.enqueueMailboxSync(mailboxId, true);
  }

  private async getSeriesChildCountMap(params: {
    mailboxId: string;
    seriesMasterIds: string[];
    includeCancelled: boolean;
    includePast: boolean;
  }) {
    if (params.seriesMasterIds.length === 0) {
      return new Map<string, number>();
    }
    const where: Prisma.OutlookEventSnapshotWhereInput = {
      mailboxId: params.mailboxId,
      seriesMasterId: { in: params.seriesMasterIds },
      eventType: { in: ['occurrence', 'exception'] },
      ...(params.includeCancelled ? {} : { isCancelled: false }),
      ...(params.includePast ? {} : { startTime: { gte: new Date() } }),
    };
    const grouped = await this.prisma.outlookEventSnapshot.groupBy({
      by: ['seriesMasterId'],
      where,
      _count: { _all: true },
    });
    const map = new Map<string, number>();
    grouped.forEach((item) => {
      if (item.seriesMasterId) {
        map.set(item.seriesMasterId, item._count._all);
      }
    });
    return map;
  }

  async listManagedSeriesChildren(
    seriesMasterId: string,
    query: ListManagedSeriesChildrenQueryDto,
    _actor: { id: string; email: string },
  ) {
    const items = await this.outlookSyncRepository.listManagedSeriesChildren({
      seriesMasterId,
      mailboxId: query.mailboxId,
    });
    const normalizedItems = await this.populateBindingOwnerFallback(items);
    return {
      seriesMasterId,
      items: normalizedItems,
      total: normalizedItems.length,
    };
  }

  async manageBinding(payload: ManageOutlookBindingDto, actor: { id: string; email: string }) {
    if (payload.action === 'UNMANAGE') {
      return this.unmanageBindingByGraphEvent(payload, actor);
    }
    if (payload.action !== 'MANAGE') {
      throw new MeetingAttendanceError(400, 'Unsupported action');
    }

    const mailbox = await this.outlookSyncRepository.findMailboxById(payload.mailboxId);
    if (!mailbox || !mailbox.isEnabled) {
      throw new MeetingAttendanceError(400, 'Source mailbox unavailable');
    }

    const rawEvent = await this.fetchEventById(mailbox.mailboxEmail, payload.graphEventId);
    if (!rawEvent) {
      throw new MeetingAttendanceError(404, 'Outlook event not found');
    }

    const rootEvent = await this.resolveManageRootEvent(mailbox.mailboxEmail, rawEvent);
    if (rootEvent.isCancelled) {
      throw new MeetingAttendanceError(400, 'Cancelled Outlook events cannot be managed');
    }
    if (
      this.getNormalizedEventType(rootEvent) === 'seriesMaster'
      && this.isSeriesMasterEnded(rootEvent, new Date())
    ) {
      throw new MeetingAttendanceError(400, 'Ended Outlook series cannot be managed');
    }
    const existedBinding = await this.outlookSyncRepository.findBindingByGraphEventId(rootEvent.id);
    if (existedBinding?.ownerUserId && existedBinding.ownerUserId !== actor.id) {
      throw new MeetingAttendanceError(
        409,
        `Event already bound by ${existedBinding.ownerEmail || existedBinding.ownerUserId}, use takeover`,
      );
    }

    const meetingSeriesId = await this.ensureSeriesIfNeeded(rootEvent, mailbox.id, actor.id);
    const normalizedRootType = this.getNormalizedEventType(rootEvent);
    const meeting = normalizedRootType === 'seriesMaster'
      ? null
      : await this.createOrUpdateMeetingFromEvent(rootEvent, actor.id, meetingSeriesId);

    if (meeting) {
      await this.syncAttendees(meeting.id, rootEvent.attendees || [], rootEvent.organizer);
    }

    const cancellationSource = this.mapCancellationSource(rootEvent);
    const bootstrapQueued = normalizedRootType === 'seriesMaster';
    const binding = await this.outlookSyncRepository.upsertBindingByGraphEvent(
      rootEvent.id,
      {
        primaryMailboxId: mailbox.id,
        graphEventId: rootEvent.id,
        iCalUId: rootEvent.iCalUId || payload.iCalUId || rootEvent.id,
        graphSeriesMasterId: this.getNormalizedEventType(rootEvent) === 'seriesMaster'
          ? rootEvent.id
          : rootEvent.seriesMasterId || null,
        graphEventType: normalizedRootType,
        manageStatus: OutlookManageStatus.MANAGED,
        bootstrapStatus: bootstrapQueued ? OutlookBootstrapStatus.QUEUED : OutlookBootstrapStatus.SUCCEEDED,
        bootstrapUpdatedAt: new Date(),
        bootstrapError: null,
        ownerUserId: actor.id,
        ownerEmail: actor.email,
        cancellationSource,
        meetingId: meeting?.id || null,
        meetingSeriesId,
        syncFrom: new Date(),
        lastSyncedAt: new Date(),
      },
      {
        iCalUId: rootEvent.iCalUId || payload.iCalUId || rootEvent.id,
        graphSeriesMasterId: this.getNormalizedEventType(rootEvent) === 'seriesMaster'
          ? rootEvent.id
          : rootEvent.seriesMasterId || null,
        graphEventType: normalizedRootType,
        manageStatus: OutlookManageStatus.MANAGED,
        bootstrapStatus: bootstrapQueued ? OutlookBootstrapStatus.QUEUED : OutlookBootstrapStatus.SUCCEEDED,
        bootstrapUpdatedAt: new Date(),
        bootstrapError: null,
        ownerUserId: actor.id,
        ownerEmail: actor.email,
        cancellationSource,
        meetingId: meeting?.id || null,
        meetingSeriesId,
        lastSyncedAt: new Date(),
      },
    );
    const sourceSnapshot = await this.captureSourceVersionForBinding({
      bindingId: binding.id,
      mailboxId: mailbox.id,
      event: rootEvent,
      versionSource: 'MANAGE_BOOTSTRAP',
      meetingId: meeting?.id || null,
    });

    await this.logBindingEvent(binding.id, mailbox.id, {
      eventType: 'MANAGED',
      message: 'Binding managed manually',
      payload: {
        actorId: actor.id,
        actorEmail: actor.email,
        graphEventId: rootEvent.id,
        meetingId: meeting?.id || null,
        manageFromEventId: rawEvent.id,
        ...sourceSnapshot,
      },
    });

    if (bootstrapQueued) {
      this.enqueueSeriesBootstrapTask(mailbox, rootEvent, binding, actor.id);
    }
    return {
      binding,
      meeting,
      bootstrapQueued,
    };
  }

  private async unmanageBindingByGraphEvent(
    payload: ManageOutlookBindingDto,
    actor: { id: string; email: string },
  ) {
    const binding = await this.outlookSyncRepository.findBindingByGraphEventId(payload.graphEventId);
    if (!binding) {
      throw new MeetingAttendanceError(404, 'Outlook binding not found');
    }
    if (binding.ownerUserId && binding.ownerUserId !== actor.id) {
      throw new MeetingAttendanceError(409, `Event already bound by ${binding.ownerEmail || binding.ownerUserId}, use takeover`);
    }
    await this.unmanageBinding(binding.id, actor);
    return { id: binding.id, manageStatus: 'PENDING_SELECTION' };
  }

  async resumeSync(bindingId: string, actor: { id: string; email: string }) {
    const binding = await this.outlookSyncRepository.findBindingById(bindingId);
    if (!binding) {
      throw new MeetingAttendanceError(404, 'Outlook binding not found');
    }
    if (binding.syncMode !== OutlookBindingSyncMode.LOCKED_BY_LOCAL_EDIT) {
      throw new MeetingAttendanceError(400, 'Binding is not locally locked');
    }

    await this.outlookSyncRepository.updateBindingSyncState(binding.id, {
      syncMode: OutlookBindingSyncMode.AUTO,
      localOverrideAt: null,
      localOverrideByUserId: null,
      localOverrideByEmail: null,
      localOverrideReason: null,
      localOverrideFields: null,
    });

    await this.logBindingEvent(binding.id, binding.primaryMailboxId, {
      eventType: 'RESUME_SYNC',
      message: 'Local override cleared; resumed automatic Outlook sync',
      payload: { actorId: actor.id, actorEmail: actor.email },
    });

    return {
      message: 'Outlook sync resumed',
      bindingId: binding.id,
      syncMode: OutlookBindingSyncMode.AUTO,
    };
  }

  async unmanageBinding(bindingId: string, actor: { id: string; email: string }) {
    const binding = await this.outlookSyncRepository.findBindingById(bindingId);
    if (!binding) {
      throw new MeetingAttendanceError(404, 'Outlook binding not found');
    }
    if (binding.ownerUserId && binding.ownerUserId !== actor.id) {
      throw new MeetingAttendanceError(409, `Event already bound by ${binding.ownerEmail || binding.ownerUserId}, use takeover`);
    }

    const manageStatus = OutlookManageStatus.PENDING_SELECTION;
    await this.outlookSyncRepository.updateBindingSyncState(binding.id, {
      manageStatus,
      cancellationSource: null,
      lastSyncedAt: new Date(),
      bootstrapStatus: null,
      bootstrapError: null,
      bootstrapUpdatedAt: new Date(),
    });

    if (binding.graphEventType === 'seriesMaster') {
      await this.prisma.outlookMeetingBinding.updateMany({
        where: {
          graphSeriesMasterId: binding.graphEventId,
          graphEventId: { not: binding.graphEventId },
        },
        data: {
          manageStatus,
          cancellationSource: null,
          lastSyncedAt: new Date(),
          bootstrapStatus: null,
          bootstrapError: null,
          bootstrapUpdatedAt: new Date(),
        },
      });
    }

    await this.logBindingEvent(binding.id, binding.primaryMailboxId, {
      eventType: 'UNMANAGE',
      message: 'Binding unmanaged',
      payload: {
        bindingId: binding.id,
        graphEventId: binding.graphEventId,
        graphEventType: binding.graphEventType,
      },
    });
    return this.outlookSyncRepository.findBindingById(binding.id);
  }

  async takeoverBinding(
    bindingId: string,
    payload: TakeoverOutlookBindingDto,
    actor: { id: string; email: string },
  ) {
    const binding = await this.outlookSyncRepository.findBindingById(bindingId);
    if (!binding) {
      throw new MeetingAttendanceError(404, 'Binding not found');
    }

    const targetMailbox = await this.resolveCandidateMailbox(payload.mailboxId, actor.email);
    if (!targetMailbox) {
      throw new MeetingAttendanceError(400, 'Current actor mailbox is not configured as source mailbox');
    }
    const takeoverAt = new Date();

    const event = await this.fetchEventById(targetMailbox.mailboxEmail, binding.graphEventId);
    if (!event) {
      throw new MeetingAttendanceError(400, 'Takeover mailbox cannot access event');
    }

    const rootEvent = await this.resolveManageRootEvent(targetMailbox.mailboxEmail, event);
    const meetingSeriesId = await this.ensureSeriesIfNeeded(rootEvent, targetMailbox.id, actor.id);
    const normalizedRootType = this.getNormalizedEventType(rootEvent);
    const meeting = normalizedRootType === 'seriesMaster'
      ? null
      : await this.createOrUpdateMeetingFromEvent(rootEvent, actor.id, meetingSeriesId);
    if (meeting) {
      await this.syncAttendees(meeting.id, rootEvent.attendees || [], rootEvent.organizer);
    }

    if (binding.graphEventType === 'seriesMaster') {
      await this.prisma.outlookMeetingBinding.updateMany({
        where: {
          graphSeriesMasterId: binding.graphEventId,
        },
        data: {
          ownerUserId: actor.id,
          ownerEmail: actor.email,
          primaryMailboxId: targetMailbox.id,
          syncFrom: takeoverAt,
        },
      });
    }

    const updated = await this.outlookSyncRepository.takeoverBinding({
      id: binding.id,
      ownerUserId: actor.id,
      ownerEmail: actor.email,
      primaryMailboxId: targetMailbox.id,
      syncFrom: takeoverAt,
    });
    await this.outlookSyncRepository.updateBindingMeetingLink(updated.id, {
      meetingId: meeting?.id || null,
      meetingSeriesId: meetingSeriesId || null,
    });
    const takeoverBootstrapQueued = this.getNormalizedEventType(rootEvent) === 'seriesMaster';
    await this.outlookSyncRepository.updateBindingSyncState(updated.id, {
      manageStatus: OutlookManageStatus.MANAGED,
      cancellationSource: this.mapCancellationSource(rootEvent),
      lastSyncedAt: new Date(),
      bootstrapStatus: takeoverBootstrapQueued ? OutlookBootstrapStatus.QUEUED : OutlookBootstrapStatus.SUCCEEDED,
      bootstrapError: null,
      bootstrapUpdatedAt: new Date(),
    });

    await this.logBindingEvent(updated.id, targetMailbox.id, {
      eventType: 'TAKEOVER',
      message: 'Binding taken over',
      payload: {
        actorId: actor.id,
        actorEmail: actor.email,
        graphEventId: updated.graphEventId,
        previousOwnerUserId: binding.ownerUserId || null,
        previousOwnerEmail: binding.ownerEmail || null,
      },
    });

    if (takeoverBootstrapQueued) {
      this.enqueueSeriesBootstrapTask(
        targetMailbox,
        rootEvent,
        {
          id: updated.id,
          graphEventType: updated.graphEventType,
          graphEventId: updated.graphEventId,
          primaryMailboxId: updated.primaryMailboxId,
          meetingSeriesId: meetingSeriesId || updated.meetingSeriesId || null,
          meetingId: meeting?.id || null,
          syncFrom: takeoverAt,
        },
        actor.id,
      );
    }
    return this.outlookSyncRepository.findBindingById(updated.id);
  }

  async listBindingOccurrenceExclusions(
    bindingId: string,
    query: ListOutlookSeriesOccurrenceExclusionsQueryDto,
  ) {
    const binding = await this.outlookSyncRepository.findBindingById(bindingId);
    if (!binding) {
      throw new MeetingAttendanceError(404, 'Binding not found');
    }

    const page = Math.max(1, query.page || 1);
    const pageSize = Math.max(1, Math.min(200, query.pageSize || 20));
    const [items, total] = await Promise.all([
      this.outlookSyncRepository.listSeriesOccurrenceExclusions({ bindingId, page, pageSize }),
      this.outlookSyncRepository.countSeriesOccurrenceExclusions(bindingId),
    ]);

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

  async excludeSeriesOccurrence(
    bindingId: string,
    payload: ExcludeOutlookSeriesOccurrenceDto,
    actor: { id: string; email: string },
  ) {
    const binding = await this.outlookSyncRepository.findBindingById(bindingId);
    if (!binding) {
      throw new MeetingAttendanceError(404, 'Binding not found');
    }
    if (binding.manageStatus !== OutlookManageStatus.MANAGED || binding.graphEventType !== 'seriesMaster') {
      throw new MeetingAttendanceError(400, 'Only managed series binding supports occurrence exclusion');
    }

    const effectiveMailboxId = binding.primaryMailboxId;
    const mailbox = await this.outlookSyncRepository.findMailboxById(effectiveMailboxId);
    if (!mailbox) {
      throw new MeetingAttendanceError(400, 'Effective mailbox unavailable');
    }

    const occurrence = await this.fetchEventById(mailbox.mailboxEmail, payload.occurrenceGraphEventId);
    if (!occurrence) {
      throw new MeetingAttendanceError(404, 'Occurrence not found');
    }
    if (!['occurrence', 'exception'].includes(occurrence.type || '')) {
      throw new MeetingAttendanceError(400, 'Only occurrence/exception can be excluded');
    }
    if (occurrence.seriesMasterId !== binding.graphEventId) {
      throw new MeetingAttendanceError(400, 'Occurrence is not under the selected series');
    }

    const exclusion = await this.outlookSyncRepository.createSeriesOccurrenceExclusion({
      bindingId,
      occurrenceGraphEventId: payload.occurrenceGraphEventId,
      iCalUId: payload.iCalUId || occurrence.iCalUId || null,
      reason: payload.reason || null,
      createdByEmail: actor.email,
    });

    const occurrenceBinding = await this.prisma.outlookMeetingBinding.findFirst({
      where: {
        primaryMailboxId: binding.primaryMailboxId,
        graphEventId: payload.occurrenceGraphEventId,
      },
    });
    if (occurrenceBinding) {
      await this.outlookSyncRepository.updateBindingSyncState(occurrenceBinding.id, {
        manageStatus: OutlookManageStatus.DISABLED,
        lastSyncedAt: new Date(),
        bootstrapStatus: null,
        bootstrapError: null,
        bootstrapUpdatedAt: new Date(),
      });
      if (occurrenceBinding.meetingId) {
        await this.prisma.meeting.update({
          where: { id: occurrenceBinding.meetingId },
          data: { status: MeetingStatus.CANCELLED },
        });
      }
    }

    await this.logBindingEvent(binding.id, binding.primaryMailboxId, {
      eventType: 'SERIES_OCCURRENCE_EXCLUDED',
      message: 'Series occurrence excluded',
      payload: {
        occurrenceGraphEventId: payload.occurrenceGraphEventId,
        reason: payload.reason || null,
        actorId: actor.id,
        actorEmail: actor.email,
      },
    });
    return exclusion;
  }

  async removeSeriesOccurrenceExclusion(exclusionId: string, actor: { id: string; email: string }) {
    const exclusion = await this.outlookSyncRepository.findSeriesOccurrenceExclusionById(exclusionId);
    if (!exclusion) {
      throw new MeetingAttendanceError(404, 'Occurrence exclusion not found');
    }

    await this.outlookSyncRepository.deleteSeriesOccurrenceExclusion(exclusionId);

    const binding = exclusion.binding;
    await this.logBindingEvent(binding.id, binding.primaryMailboxId, {
      eventType: 'SERIES_OCCURRENCE_EXCLUSION_REMOVED',
      message: 'Series occurrence exclusion removed',
      payload: {
        exclusionId,
        occurrenceGraphEventId: exclusion.occurrenceGraphEventId,
        actorId: actor.id,
        actorEmail: actor.email,
      },
    });
    return { removed: true, exclusionId };
  }

  async listManagedBindings(query: ListManagedOutlookBindingsQueryDto) {
    const mailbox = await this.outlookSyncRepository.findMailboxById(query.mailboxId);
    if (!mailbox) {
      throw new MeetingAttendanceError(404, 'Source mailbox not found');
    }

    const page = query.page ?? 1;
    const pageSize = query.pageSize ?? 20;
    const [items, total] = await Promise.all([
      this.outlookSyncRepository.listManagedBindings({
        mailboxId: query.mailboxId,
        keyword: query.keyword,
        eventType: query.eventType,
        status: query.status,
        page,
        pageSize,
      }),
      this.outlookSyncRepository.countManagedBindings({
        mailboxId: query.mailboxId,
        keyword: query.keyword,
        eventType: query.eventType,
        status: query.status,
      }),
    ]);

    return {
      mailbox: {
        id: mailbox.id,
        mailboxEmail: mailbox.mailboxEmail,
      },
      items,
      pagination: {
        page,
        pageSize,
        total,
        totalPages: Math.max(1, Math.ceil(total / pageSize)),
      },
    };
  }

  async listAllManagedBindings(query: ListAllManagedOutlookBindingsQueryDto) {
    const page = query.page ?? 1;
    const pageSize = query.pageSize ?? 20;
    const [items, total] = await Promise.all([
      this.outlookSyncRepository.listAllManagedBindings({
        keyword: query.keyword,
        eventType: query.eventType,
        status: query.status,
        page,
        pageSize,
      }),
      this.outlookSyncRepository.countAllManagedBindings({
        keyword: query.keyword,
        eventType: query.eventType,
        status: query.status,
      }),
    ]);
    const normalizedItems = await this.populateBindingOwnerFallback(items);

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

  async getBindingDetail(bindingId: string) {
    const binding = await this.outlookSyncRepository.findBindingById(bindingId);
    if (!binding) {
      throw new MeetingAttendanceError(404, 'Binding not found');
    }
    const [normalized] = await this.populateBindingOwnerFallback([binding]);
    return normalized || binding;
  }

  async getBindingHistory(bindingId: string, query: ListOutlookBindingHistoryQueryDto) {
    const binding = await this.outlookSyncRepository.findBindingById(bindingId);
    if (!binding) {
      throw new MeetingAttendanceError(404, 'Binding not found');
    }
    const page = Math.max(1, query.page || 1);
    const pageSize = Math.max(1, Math.min(200, query.pageSize || 20));
    const startDate = this.parseOptionalDate(query.startDate, 'Invalid history startDate');
    const endDate = this.parseOptionalDate(query.endDate, 'Invalid history endDate');

    if (startDate && endDate && startDate > endDate) {
      throw new MeetingAttendanceError(400, 'startDate must be less than or equal to endDate');
    }

    const [items, total] = await Promise.all([
      this.outlookSyncRepository.listBindingSyncEventLogs({
        bindingId,
        page,
        pageSize,
        startDate,
        endDate,
        eventType: query.eventType?.trim() || undefined,
        stage: query.stage?.trim() || undefined,
        onlyError: Boolean(query.onlyError),
      }),
      this.outlookSyncRepository.countBindingSyncEventLogs({
        bindingId,
        startDate,
        endDate,
        eventType: query.eventType?.trim() || undefined,
        stage: query.stage?.trim() || undefined,
        onlyError: Boolean(query.onlyError),
      }),
    ]);

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

  async exportBindingHistoryCsv(bindingId: string, query: ListOutlookBindingHistoryQueryDto) {
    const binding = await this.outlookSyncRepository.findBindingById(bindingId);
    if (!binding) {
      throw new MeetingAttendanceError(404, 'Binding not found');
    }

    const startDate = this.parseOptionalDate(query.startDate, 'Invalid history startDate');
    const endDate = this.parseOptionalDate(query.endDate, 'Invalid history endDate');
    if (startDate && endDate && startDate > endDate) {
      throw new MeetingAttendanceError(400, 'startDate must be less than or equal to endDate');
    }

    const items = await this.outlookSyncRepository.listBindingSyncEventLogsForExport({
      bindingId,
      startDate,
      endDate,
      eventType: query.eventType?.trim() || undefined,
      stage: query.stage?.trim() || undefined,
      onlyError: Boolean(query.onlyError),
      maxRows: 5000,
    });

    const rows = items.map((item: any) => {
      const payload = (item.payload || {}) as Record<string, any>;
      const stage = payload.stage || '';
      const errorCode = payload.errorCode || (payload.statusCode ? String(payload.statusCode) : '');
      const message = `${stage ? `[${stage}] ` : ''}${item.message || payload.errorMessage || ''}`;
      return [
        this.formatCsvDate(item.createdAt),
        item.eventType || '',
        item.resultStatus || '',
        errorCode,
        stage,
        message,
      ];
    });

    const header = ['time', 'eventType', 'resultStatus', 'errorCode', 'stage', 'message'];
    const content = [header, ...rows]
      .map((line) => line.map((value: string) => this.escapeCsv(String(value ?? ''))).join(','))
      .join('\n');

    return {
      filename: `outlook-binding-history-${bindingId}.csv`,
      content,
    };
  }

  async triggerReconcile(mailboxId?: string) {
    if (mailboxId) {
      const mailbox = await this.outlookSyncRepository.findMailboxById(mailboxId);
      if (!mailbox) {
        throw new MeetingAttendanceError(404, 'Source mailbox not found');
      }
      this.enqueueMailboxSync(mailbox.id, true);
      return { accepted: true, mailboxId };
    }

    await this.reconcileAllEnabledMailboxes();
    return { accepted: true, mailboxId: 'all' };
  }

  async handleChangeNotifications(notifications: any[]) {
    for (const item of notifications) {
      const graphSubscriptionId = item?.subscriptionId;
      if (!graphSubscriptionId) {
        continue;
      }

      const valid = this.validateNotificationClientState(item?.clientState);
      if (!valid) {
        logger.warn(`Ignored notification with invalid clientState, subscriptionId=${graphSubscriptionId}`);
        continue;
      }

      const subscription = await this.outlookSyncRepository.findSubscriptionByGraphSubscriptionId(graphSubscriptionId);
      if (!subscription || !subscription.mailbox.isEnabled) {
        continue;
      }

      this.enqueueMailboxSync(subscription.mailboxId, false);
    }
  }

  async handleLifecycleNotifications(lifecycleEvents: any[]) {
    for (const item of lifecycleEvents) {
      const graphSubscriptionId = item?.subscriptionId;
      if (!graphSubscriptionId) {
        continue;
      }

      const subscription = await this.outlookSyncRepository.findSubscriptionByGraphSubscriptionId(graphSubscriptionId);
      if (!subscription) {
        continue;
      }

      const eventType = item?.lifecycleEvent;
      if (eventType === 'subscriptionRemoved') {
        await this.outlookSyncRepository.updateSubscription(subscription.id, {
          status: OutlookSubscriptionStatus.EXPIRED,
          lastError: 'subscriptionRemoved',
        });
        continue;
      }

      if (eventType === 'reauthorizationRequired' || eventType === 'missed') {
        await this.outlookSyncRepository.updateSubscription(subscription.id, {
          status: OutlookSubscriptionStatus.ERROR,
          lastError: eventType,
        });
      }
    }
  }

  async reconcileAllEnabledMailboxes() {
    const mailboxes = await this.outlookSyncRepository.listEnabledMailboxes();
    for (const mailbox of mailboxes) {
      this.enqueueMailboxSync(mailbox.id, true);
    }
  }

  async syncAllEnabledMailboxesDelta() {
    const mailboxes = await this.outlookSyncRepository.listEnabledMailboxes();
    for (const mailbox of mailboxes) {
      this.enqueueMailboxSync(mailbox.id, false);
    }
  }

  async renewSubscriptionsIfNeeded() {
    const settings = await this.outlookSyncRepository.getOrCreateSettings();
    const renewBefore = new Date(Date.now() + settings.renewBeforeMinutes * 60 * 1000);
    const toRenew = await this.outlookSyncRepository.listSubscriptionsToRenew(renewBefore);

    for (const subscription of toRenew) {
      try {
        const renewed = await this.renewGraphSubscription(subscription.graphSubscriptionId);
        await this.outlookSyncRepository.updateSubscription(subscription.id, {
          status: OutlookSubscriptionStatus.ACTIVE,
          expirationAt: new Date(renewed.expirationDateTime),
          lastError: null,
        });
      } catch (error: any) {
        await this.outlookSyncRepository.updateSubscription(subscription.id, {
          status: OutlookSubscriptionStatus.ERROR,
          lastError: error?.message || 'renew failed',
        });
      }
    }

    return { renewed: toRenew.length };
  }

  validateNotificationClientState(inputClientState?: string) {
    const expected = this.configService.get<string>('GRAPH_NOTIFICATION_CLIENT_STATE');
    if (!expected) {
      throw new MeetingAttendanceError(500, 'GRAPH_NOTIFICATION_CLIENT_STATE is not configured');
    }
    return inputClientState === expected;
  }

  async syncMailboxDelta(mailboxId: string, isReconcile: boolean) {
    return this.runMailboxDeltaSync(mailboxId, isReconcile);
  }

  private enqueueMailboxSync(mailboxId: string, isReconcile: boolean) {
    const state = this.mailboxSyncQueue.get(mailboxId) || {
      running: false,
      pending: false,
      pendingReconcile: false,
    };
    state.pending = true;
    if (isReconcile) {
      state.pendingReconcile = true;
    }
    this.mailboxSyncQueue.set(mailboxId, state);
    if (!state.running) {
      void this.drainMailboxSyncQueue(mailboxId);
    }
  }

  private async drainMailboxSyncQueue(mailboxId: string) {
    const state = this.mailboxSyncQueue.get(mailboxId);
    if (!state || state.running) {
      return;
    }
    state.running = true;
    this.mailboxSyncQueue.set(mailboxId, state);
    try {
      while (state.pending) {
        const runReconcile = state.pendingReconcile;
        state.pending = false;
        state.pendingReconcile = false;
        try {
          await this.runMailboxDeltaSync(mailboxId, runReconcile);
        } catch (error) {
          logger.error(`Queued mailbox sync failed for ${mailboxId}: ${String(error)}`);
        }
      }
    } finally {
      state.running = false;
      if (!state.pending) {
        this.mailboxSyncQueue.delete(mailboxId);
      } else {
        this.mailboxSyncQueue.set(mailboxId, state);
      }
    }
  }

  private async runMailboxDeltaSync(mailboxId: string, isReconcile: boolean) {
    const mailbox = await this.outlookSyncRepository.findMailboxById(mailboxId);
    if (!mailbox || !mailbox.isEnabled) {
      return;
    }
    const latestSubscription = await this.outlookSyncRepository.getLatestActiveSubscription(mailboxId);

    const settings = await this.outlookSyncRepository.getOrCreateSettings();
    const cursor = await this.outlookSyncRepository.getCursorByMailboxId(mailboxId);

    const client = this.getGraphClient();
    let nextLink: string | null = cursor?.deltaToken || null;
    if (!nextLink) {
      nextLink = `/users/${mailbox.mailboxEmail}/events/delta`;
    }
    const maxPageSize = Math.max(10, Math.min(500, settings.deltaBatchSize));

    let deltaLink: string | null = null;
    const processedEvents = new Set<string>();

    while (nextLink) {
      let response: any;
      try {
        response = await client
          .api(nextLink)
          .header('Prefer', `odata.maxpagesize=${maxPageSize}`)
          .get();
      } catch (error) {
        if (latestSubscription) {
          await this.outlookSyncRepository.updateSubscription(latestSubscription.id, {
            status: OutlookSubscriptionStatus.ERROR,
            lastError: (error as any)?.body?.error?.message || (error as any)?.message || 'delta sync failed',
          });
        }
        throw this.wrapGraphError(error, `Failed to sync mailbox delta (${mailbox.mailboxEmail})`);
      }
      const events = (response?.value || []) as GraphEventInfo[];

      for (const event of events) {
        if (!event?.id || processedEvents.has(event.id)) {
          continue;
        }
        processedEvents.add(event.id);

        if (event['@removed']) {
          await this.deleteEventSnapshot(mailboxId, event.id);
          try {
            await this.applyRemovedEvent(event.id, mailboxId);
          } catch (error) {
            await this.logSyncErrorByGraphEventId(
              event.id,
              mailboxId,
              'APPLY_REMOVED_EVENT',
              error,
            );
            logger.error(
              `Failed to apply removed event ${event.id} for mailbox ${mailbox.mailboxEmail}: ${String(error)}`,
            );
          }
          continue;
        }

        const hydratedEvent = await this.hydrateEventForSync(mailbox.mailboxEmail, event);
        await this.upsertEventSnapshot(mailboxId, hydratedEvent);

        try {
          await this.applyUpdatedEvent(hydratedEvent, mailboxId, mailbox.mailboxEmail, { alreadyHydrated: true });
        } catch (error) {
          await this.logSyncErrorByGraphEventId(
            hydratedEvent.id,
            mailboxId,
            'APPLY_UPDATED_EVENT',
            error,
          );
          logger.error(
            `Failed to apply updated event ${hydratedEvent.id} for mailbox ${mailbox.mailboxEmail}: ${String(error)}`,
          );
        }
      }

      nextLink = response?.['@odata.nextLink'] || null;
      if (response?.['@odata.deltaLink']) {
        deltaLink = response['@odata.deltaLink'];
      }
    }

    if (deltaLink) {
      await this.outlookSyncRepository.upsertCursor(mailboxId, deltaLink, {
        lastSyncedAt: new Date(),
        lastReconciledAt: isReconcile ? new Date() : undefined,
      });
    }

    if (isReconcile) {
      await this.reconcileManagedBindings(mailbox.id, mailbox.mailboxEmail);
      await this.backfillCandidateSeriesChildrenSnapshotsForReconcile(
        { id: mailbox.id, mailboxEmail: mailbox.mailboxEmail },
        new Date(),
      );
    }

    if (latestSubscription) {
      await this.outlookSyncRepository.updateSubscription(latestSubscription.id, {
        status: OutlookSubscriptionStatus.ACTIVE,
        lastError: null,
      });
    }
  }

  private async applyRemovedEvent(graphEventId: string, mailboxId: string) {
    const bindings = await this.outlookSyncRepository.findBindingsByGraphEventId(graphEventId);
    for (const binding of bindings) {
      const effectiveMailboxId = binding.primaryMailboxId;
      if (effectiveMailboxId !== mailboxId) {
        continue;
      }

      await this.outlookSyncRepository.updateBindingSyncState(binding.id, {
        cancellationSource: OutlookCancellationSource.DELETED,
        lastSyncedAt: new Date(),
      });
      await this.logBindingEvent(binding.id, binding.primaryMailboxId, {
        eventType: 'SYNC_REMOVED',
        message: 'Graph event removed',
        payload: {
          graphEventId,
          cancellationSource: OutlookCancellationSource.DELETED,
        },
      });

      if (binding.meetingId) {
        await this.prisma.meeting.update({
          where: { id: binding.meetingId },
          data: { status: MeetingStatus.CANCELLED },
        });
      }
    }

    const seriesBindings = await this.prisma.outlookMeetingBinding.findMany({
      where: {
        graphEventType: 'seriesMaster',
        manageStatus: OutlookManageStatus.MANAGED,
        primaryMailboxId: mailboxId,
      },
    });
    for (const seriesBinding of seriesBindings) {
      const excluded = await this.outlookSyncRepository.isSeriesOccurrenceExcluded(
        seriesBinding.id,
        graphEventId,
      );
      if (excluded) {
        continue;
      }
      const occurrenceBinding = await this.prisma.outlookMeetingBinding.findFirst({
        where: {
          primaryMailboxId: seriesBinding.primaryMailboxId,
          graphEventId,
          graphSeriesMasterId: seriesBinding.graphEventId,
        },
      });
      if (!occurrenceBinding) {
        continue;
      }
      await this.outlookSyncRepository.updateBindingSyncState(occurrenceBinding.id, {
        cancellationSource: OutlookCancellationSource.DELETED,
        lastSyncedAt: new Date(),
      });
      if (occurrenceBinding.meetingId) {
        await this.prisma.meeting.update({
          where: { id: occurrenceBinding.meetingId },
          data: { status: MeetingStatus.CANCELLED },
        });
      }
    }
  }

  private async applyUpdatedEvent(
    event: GraphEventInfo,
    mailboxId: string,
    mailboxEmail: string,
    options?: { alreadyHydrated?: boolean },
  ) {
    const eventForSync = options?.alreadyHydrated ? event : await this.hydrateEventForSync(mailboxEmail, event);
    let bindings = await this.outlookSyncRepository.findBindingsByGraphEventId(eventForSync.id);
    if (!bindings.length && eventForSync.seriesMasterId) {
      const seriesBindings = await this.outlookSyncRepository.findBindingsByGraphEventId(eventForSync.seriesMasterId);
      bindings = seriesBindings.filter((item) => item.graphEventType === 'seriesMaster');
    }
    if (!bindings.length) {
      return;
    }

    for (const binding of bindings) {
      if (binding.manageStatus !== OutlookManageStatus.MANAGED) {
        continue;
      }

      const effectiveMailboxId = binding.primaryMailboxId;
      if (effectiveMailboxId !== mailboxId) {
        continue;
      }

      if (
        binding.graphEventType === 'seriesMaster'
        && eventForSync.id !== binding.graphEventId
        && ['occurrence', 'exception'].includes(this.getNormalizedEventType(eventForSync))
      ) {
        const excluded = await this.outlookSyncRepository.isSeriesOccurrenceExcluded(binding.id, eventForSync.id);
        if (excluded) {
          continue;
        }
        if (!this.isEventInSyncWindow(eventForSync, binding.syncFrom)) {
          continue;
        }
      }
      const isSeriesMasterRootUpdate =
        binding.graphEventType === 'seriesMaster'
        && eventForSync.id === binding.graphEventId;

      const targetBinding = await this.ensureOccurrenceBindingForSeriesEvent(binding, eventForSync);

      const normalizedType = this.getNormalizedEventType(eventForSync);
      const refreshedSeriesId = normalizedType === 'single'
        ? null
        : await this.ensureSeriesIfNeeded(
          eventForSync,
          binding.primaryMailboxId,
          binding.meeting?.creatorId || '',
        );
      const meetingSeriesId = refreshedSeriesId || binding.meetingSeriesId || null;
      const resolvedCreatorId = binding.meeting?.creatorId || await this.getFallbackCreatorId();
      if (normalizedType === 'seriesMaster') {
        const sourceSnapshot = await this.captureSourceVersionForBinding({
          bindingId: targetBinding.id,
          mailboxId: targetBinding.primaryMailboxId,
          event: eventForSync,
          versionSource: 'WEBHOOK_DELTA',
          meetingId: targetBinding.meetingId || null,
        });
        if (targetBinding.meetingId || targetBinding.meetingSeriesId !== meetingSeriesId) {
          await this.outlookSyncRepository.updateBindingMeetingLink(targetBinding.id, {
            meetingId: null,
            meetingSeriesId: meetingSeriesId || null,
          });
        }
        await this.outlookSyncRepository.updateBindingSyncState(targetBinding.id, {
          cancellationSource: this.mapCancellationSource(eventForSync),
          manageStatus: OutlookManageStatus.MANAGED,
          lastSyncedAt: new Date(),
        });
        await this.logBindingEvent(targetBinding.id, targetBinding.primaryMailboxId, {
          eventType: 'SYNC_UPDATED',
          message: 'Graph series master updated',
          payload: {
            graphEventId: eventForSync.id,
            isCancelled: Boolean(eventForSync.isCancelled),
            meetingId: null,
            meetingStatus: null,
            ...sourceSnapshot,
          },
        });

        if (isSeriesMasterRootUpdate) {
          await this.bootstrapSeriesOccurrences(
            { id: mailboxId, mailboxEmail },
            eventForSync,
            {
              id: targetBinding.id,
              graphEventType: targetBinding.graphEventType,
              graphEventId: targetBinding.graphEventId,
              primaryMailboxId: targetBinding.primaryMailboxId,
              meetingSeriesId: meetingSeriesId || targetBinding.meetingSeriesId || null,
              meetingId: null,
              syncFrom: targetBinding.syncFrom || binding.syncFrom || null,
              syncMode: targetBinding.syncMode || binding.syncMode || OutlookBindingSyncMode.AUTO,
              localOverrideReason: targetBinding.localOverrideReason || binding.localOverrideReason || null,
              localOverrideFields: targetBinding.localOverrideFields ?? binding.localOverrideFields ?? null,
            },
            resolvedCreatorId,
          );
        }
        continue;
      }

      const sourceSnapshot = await this.captureSourceVersionForBinding({
        bindingId: targetBinding.id,
        mailboxId: targetBinding.primaryMailboxId,
        event: eventForSync,
        versionSource: 'WEBHOOK_DELTA',
        meetingId: targetBinding.meetingId || null,
      });

      if (targetBinding.syncMode === OutlookBindingSyncMode.LOCKED_BY_LOCAL_EDIT) {
        await this.outlookSyncRepository.updateBindingSyncState(targetBinding.id, {
          lastSyncedAt: new Date(),
        });
        await this.logBindingEvent(targetBinding.id, targetBinding.primaryMailboxId, {
          eventType: 'SYNC_SKIPPED_LOCAL_OVERRIDE',
          resultStatus: 'INFO',
          message: 'Skipped applying Outlook update because meeting is locally maintained',
          payload: {
            ...sourceSnapshot,
            graphEventId: eventForSync.id,
            meetingId: targetBinding.meetingId,
            reason: targetBinding.localOverrideReason || 'LOCAL_OVERRIDE',
            changedFields: targetBinding.localOverrideFields || [],
            triggerSource: 'WEBHOOK_DELTA',
          },
        });
        continue;
      }

      const meeting = await this.createOrUpdateMeetingFromEvent(
        eventForSync,
        resolvedCreatorId,
        meetingSeriesId,
      );

      if (!targetBinding.meetingId || targetBinding.meetingId !== meeting.id) {
        await this.outlookSyncRepository.updateBindingMeetingLink(targetBinding.id, {
          meetingId: meeting.id,
          meetingSeriesId: meetingSeriesId || null,
        });
      }

      await this.syncAttendees(meeting.id, eventForSync.attendees || [], eventForSync.organizer);
      await this.outlookSyncRepository.updateBindingSyncState(targetBinding.id, {
        cancellationSource: this.mapCancellationSource(eventForSync),
        manageStatus: OutlookManageStatus.MANAGED,
        lastSyncedAt: new Date(),
      });
      await this.logBindingEvent(targetBinding.id, targetBinding.primaryMailboxId, {
        eventType: 'SYNC_UPDATED',
        message: 'Graph event updated',
        payload: {
          graphEventId: eventForSync.id,
          isCancelled: Boolean(eventForSync.isCancelled),
          meetingId: meeting.id,
          meetingStatus: meeting.status,
          ...sourceSnapshot,
        },
      });

      // Reconcile should self-heal manually deleted local series/occurrences for managed series roots.
      if (isSeriesMasterRootUpdate) {
        await this.bootstrapSeriesOccurrences(
          { id: mailboxId, mailboxEmail },
          eventForSync,
          {
            id: targetBinding.id,
            graphEventType: targetBinding.graphEventType,
            graphEventId: targetBinding.graphEventId,
            primaryMailboxId: targetBinding.primaryMailboxId,
            meetingSeriesId: meetingSeriesId || targetBinding.meetingSeriesId || null,
            meetingId: meeting.id,
            syncFrom: targetBinding.syncFrom || binding.syncFrom || null,
          },
          resolvedCreatorId,
        );
      }
    }
  }

  private async reconcileManagedBindings(mailboxId: string, mailboxEmail: string) {
    const bindings = await this.outlookSyncRepository.listBindingsByMailbox(mailboxId);
    for (const binding of bindings) {
      if (binding.manageStatus !== OutlookManageStatus.MANAGED) {
        continue;
      }
      if (!binding.graphEventId) {
        continue;
      }
      try {
        const event = await this.fetchEventById(mailboxEmail, binding.graphEventId);
        if (!event) {
          continue;
        }
        await this.applyUpdatedEvent(event, mailboxId, mailboxEmail);
      } catch (error) {
        await this.logSyncErrorByGraphEventId(
          binding.graphEventId,
          mailboxId,
          'RECONCILE_MANAGED_BINDING',
          error,
        );
        logger.warn(
          `Failed to reconcile managed binding ${binding.id} (${binding.graphEventId}): ${String(error)}`,
        );
      }
    }
  }

  private async resolveCandidateMailbox(mailboxId?: string, actorEmail?: string) {
    if (mailboxId) {
      const mailbox = await this.outlookSyncRepository.findMailboxById(mailboxId);
      if (!mailbox || !mailbox.isEnabled) {
        throw new MeetingAttendanceError(400, 'Source mailbox unavailable');
      }
      return mailbox;
    }

    if (!actorEmail) {
      return null;
    }

    const normalizedEmail = actorEmail.trim().toLowerCase();
    let ownMailbox = await this.outlookSyncRepository.findMailboxByEmail(normalizedEmail);
    if (!ownMailbox) {
      ownMailbox = await this.ensureActorMailbox(normalizedEmail);
    }
    if (!ownMailbox) {
      return null;
    }

    if (!ownMailbox.isEnabled) {
      const enabledMailbox = await this.updateMailboxStatus(ownMailbox.id, true);
      if (enabledMailbox) {
        return enabledMailbox;
      }
    }

    return ownMailbox;
  }

  private parseBooleanLike(value: unknown): boolean {
    if (value === true || value === false) {
      return value;
    }
    if (typeof value === 'number') {
      return value !== 0;
    }
    if (typeof value === 'string') {
      const normalized = value.trim().toLowerCase();
      if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) {
        return true;
      }
      if (['false', '0', 'no', 'n', 'off', ''].includes(normalized)) {
        return false;
      }
    }
    return false;
  }

  private async loadCandidateSnapshots(params: {
    mailboxId: string;
    actorId: string;
    startDate: Date;
    endDate: Date;
    keyword?: string;
    eventType?: string;
    includeCancelled: boolean;
    includePast: boolean;
    onlyUnmanaged: boolean;
    page: number;
    pageSize: number;
  }) {
    const keyword = params.keyword?.toLowerCase();
    const now = new Date();
    const shouldStrictFilterSeriesMaster = await this.shouldStrictFilterSeriesMaster(params.mailboxId, now);
    const whereAnd: Prisma.OutlookEventSnapshotWhereInput[] = [
      { mailboxId: params.mailboxId },
    ];
    if (params.eventType) {
      whereAnd.push({ eventType: params.eventType });
    } else {
      // 默认候选视图仅展示单次会议与系列主会议，实例通过展开按需加载。
      whereAnd.push({ eventType: { in: ['single', 'seriesMaster'] } });
    }
    if (!params.includeCancelled) {
      whereAnd.push({ isCancelled: false });
    }
    if (keyword) {
      whereAnd.push({
        OR: [
          { title: { contains: keyword, mode: 'insensitive' } },
          { organizerEmail: { contains: keyword, mode: 'insensitive' } },
          { graphEventId: { contains: keyword, mode: 'insensitive' } },
        ],
      });
    }
    whereAnd.push({
      OR: [
        { eventType: 'seriesMaster' },
        ...(params.includePast
          ? []
          : [{
            startTime: {
              gte: now,
              lte: params.endDate,
            },
          }]),
      ],
    });

    if (!params.includePast && shouldStrictFilterSeriesMaster) {
      const eligibleChildren = await this.prisma.outlookEventSnapshot.groupBy({
        by: ['seriesMasterId'],
        where: {
          mailboxId: params.mailboxId,
          eventType: { in: ['occurrence', 'exception'] },
          startTime: { gte: now, lte: params.endDate },
          ...(params.includeCancelled ? {} : { isCancelled: false }),
          seriesMasterId: { not: null },
        },
      });
      const eligibleSeriesMasterIds = eligibleChildren
        .map((item) => item.seriesMasterId)
        .filter((value): value is string => Boolean(value));
      // 当快照里还没有系列实例（occurrence/exception）时，不能把 seriesMaster 全过滤掉，
      // 否则候选页会出现“没有系列会议”的错误表现。
      if (eligibleSeriesMasterIds.length > 0) {
        whereAnd.push({
          OR: [
            { eventType: { not: 'seriesMaster' } },
            { graphEventId: { in: eligibleSeriesMasterIds } },
          ],
        });
      }
    }

    if (!params.includePast) {
      const endedSeriesMasterIds = await this.listEndedCandidateSeriesMasterIds({
        mailboxId: params.mailboxId,
        keyword: params.keyword,
        eventType: params.eventType,
        includeCancelled: params.includeCancelled,
      }, now);
      if (endedSeriesMasterIds.length > 0) {
        whereAnd.push({
          OR: [
            { eventType: { not: 'seriesMaster' } },
            { graphEventId: { notIn: endedSeriesMasterIds } },
          ],
        });
      }
    }

    if (params.onlyUnmanaged) {
      const [managedBindings, managedSeriesBindings] = await Promise.all([
        this.prisma.outlookMeetingBinding.findMany({
          where: {
            primaryMailboxId: params.mailboxId,
            manageStatus: { in: [OutlookManageStatus.MANAGED, OutlookManageStatus.SYNC_ERROR] },
          },
          select: { graphEventId: true },
        }),
        this.prisma.outlookMeetingBinding.findMany({
          where: {
            primaryMailboxId: params.mailboxId,
            graphEventType: 'seriesMaster',
            manageStatus: { in: [OutlookManageStatus.MANAGED, OutlookManageStatus.SYNC_ERROR] },
          },
          select: { graphEventId: true },
        }),
      ]);
      const managedIds = managedBindings.map((item) => item.graphEventId).filter(Boolean);
      const managedSeriesIds = managedSeriesBindings.map((item) => item.graphEventId).filter(Boolean);
      if (managedIds.length > 0) {
        whereAnd.push({ graphEventId: { notIn: managedIds } });
      }
      if (managedSeriesIds.length > 0) {
        whereAnd.push({
          OR: [
            { seriesMasterId: null },
            { seriesMasterId: { notIn: managedSeriesIds } },
          ],
        });
      }
    }

    const where: Prisma.OutlookEventSnapshotWhereInput = {
      AND: whereAnd,
    };
    const total = await this.prisma.outlookEventSnapshot.count({ where });
    if (total === 0) {
      return { total: 0, items: [] as any[] };
    }

    const snapshots = await this.prisma.outlookEventSnapshot.findMany({
      where,
      orderBy: [{ startTime: 'asc' }, { updatedAt: 'desc' }],
      skip: (params.page - 1) * params.pageSize,
      take: params.pageSize,
    });

    const graphEventIds = Array.from(new Set(snapshots.map((item) => item.graphEventId).filter(Boolean)));
    const [existedBindings, managedSeriesBindings] = await Promise.all([
      this.outlookSyncRepository.findBindingsByGraphEventIds(graphEventIds),
      this.outlookSyncRepository.listManagedSeriesBindingsByMailbox(params.mailboxId),
    ]);
    const bindingMap = new Map(existedBindings.map((item) => [item.graphEventId, item]));
    const seriesBindingMap = new Map(managedSeriesBindings.map((item) => [item.graphEventId, item]));

    return {
      total,
      items: snapshots
      .map((snapshot) => this.toCandidateItemFromSnapshot(snapshot, {
        bindingMap,
        seriesBindingMap,
        actorId: params.actorId,
        fallbackMailboxId: params.mailboxId,
      }))
      .sort((a, b) => {
        const priority = (type: string) => {
          switch (type) {
            case 'seriesMaster':
              return 0;
            case 'exception':
              return 1;
            case 'occurrence':
              return 2;
            default:
              return 3;
          }
        };
        const p = priority(a.eventType) - priority(b.eventType);
        if (p !== 0) return p;
        const ta = this.parseGraphDateTimeToMs(a.startTime, a.timezone);
        const tb = this.parseGraphDateTimeToMs(b.startTime, b.timezone);
        return ta - tb;
      }),
    };
  }

  private async listEndedCandidateSeriesMasterIds(
    params: {
      mailboxId: string;
      keyword?: string;
      eventType?: string;
      includeCancelled: boolean;
    },
    now: Date,
  ) {
    if (params.eventType && params.eventType !== 'seriesMaster') {
      return [] as string[];
    }

    const keyword = params.keyword?.toLowerCase();
    const seriesMasters = await this.prisma.outlookEventSnapshot.findMany({
      where: {
        mailboxId: params.mailboxId,
        eventType: 'seriesMaster',
        ...(params.includeCancelled ? {} : { isCancelled: false }),
        ...(keyword
          ? {
            OR: [
              { title: { contains: keyword, mode: 'insensitive' } },
              { organizerEmail: { contains: keyword, mode: 'insensitive' } },
              { graphEventId: { contains: keyword, mode: 'insensitive' } },
            ],
          }
          : {}),
      },
      select: {
        graphEventId: true,
        rawPayload: true,
      },
    });
    return seriesMasters
      .filter((item) => this.isSeriesMasterEnded(this.getGraphEventFromRawPayload(item.rawPayload), now))
      .map((item) => item.graphEventId);
  }

  private async shouldStrictFilterSeriesMaster(mailboxId: string, now: Date): Promise<boolean> {
    const cursor = await this.prisma.outlookSyncCursor.findUnique({
      where: { mailboxId },
      select: { lastSyncedAt: true },
    });
    return shouldUseStrictSeriesMasterFilter(cursor?.lastSyncedAt, now);
  }

  private async ensureActorMailbox(actorEmail: string) {
    try {
      const enabledCount = await this.prisma.outlookSyncMailbox.count({
        where: { isEnabled: true },
      });
      const mailbox = await this.outlookSyncRepository.createMailbox({
        mailboxEmail: actorEmail,
        mailboxType: OutlookMailboxType.PERSONAL,
        isPrimaryDefault: enabledCount === 0,
        isEnabled: true,
      });

      if (mailbox.isPrimaryDefault) {
        await this.outlookSyncRepository.setPrimaryDefault(mailbox.id, true);
      }

      if (this.canCreateWebhookSubscription()) {
        try {
          const subscription = await this.createGraphSubscription(mailbox.mailboxEmail);
          await this.outlookSyncRepository.createSubscription({
            mailbox: { connect: { id: mailbox.id } },
            graphSubscriptionId: subscription.id,
            resource: subscription.resource,
            status: OutlookSubscriptionStatus.ACTIVE,
            expirationAt: new Date(subscription.expirationDateTime),
          });
        } catch (error) {
          logger.warn(`Auto subscription failed for mailbox ${mailbox.mailboxEmail}: ${String(error)}`);
        }
      } else {
        logger.warn(
          `Skip auto subscription for mailbox ${mailbox.mailboxEmail}: webhook url is not public HTTPS`,
        );
      }

      return this.outlookSyncRepository.findMailboxById(mailbox.id);
    } catch (error) {
      if (
        error instanceof Prisma.PrismaClientKnownRequestError
        && error.code === 'P2002'
      ) {
        return this.outlookSyncRepository.findMailboxByEmail(actorEmail);
      }
      throw error;
    }
  }

  private async createOrUpdateMeetingFromEvent(
    event: GraphEventInfo,
    fallbackCreatorId: string,
    meetingSeriesId: string | null,
  ) {
    const now = new Date();
    const startTime = this.parseGraphDateTime(event.start, event);
    const endTime = this.parseGraphDateTime(event.end, event);
    const status = this.deriveMeetingStatus(event, startTime, endTime, now);

    const title = event.subject?.trim() || '(No Subject)';
    const normalizedType = this.getNormalizedEventType(event);
    let timezone = this.resolveEventTimezone(event);
    if (
      (normalizedType === 'occurrence' || normalizedType === 'exception')
      && meetingSeriesId
      && timezone === 'UTC'
    ) {
      const series = await this.prisma.meetingSeries.findUnique({
        where: { id: meetingSeriesId },
        select: { timezone: true },
      });
      if (series?.timezone) {
        timezone = series.timezone;
      }
    }
    const location = event.location?.displayName || null;

    if (meetingSeriesId) {
      await this.prisma.meetingSeries.update({
        where: { id: meetingSeriesId },
        data: {
          isActive: true,
          timezone,
          location: location || undefined,
        },
      }).catch(() => undefined);
    }

    const baseUrl = getMeetingAttendanceBaseUrl();
    const existingBinding = await this.prisma.outlookMeetingBinding.findFirst({
      where: {
        graphEventId: event.id,
      },
      orderBy: { updatedAt: 'desc' },
    });

    const resolvedCreatorId = await this.resolveMeetingCreatorId(event, fallbackCreatorId);

    // v1.2 从所属 series 继承 city + enforceCheckinMode（若有 series）
    let inheritedCity: string | null = null;
    let inheritedEnforce = false;
    if (meetingSeriesId) {
      const seriesRow = (await (this.prisma.meetingSeries as any).findUnique({
        where: { id: meetingSeriesId },
        select: { city: true, enforceCheckinMode: true },
      })) as any;
      if (seriesRow) {
        inheritedCity = seriesRow.city ?? null;
        inheritedEnforce = !!seriesRow.enforceCheckinMode;
      }
    }

    if (existingBinding?.meetingId) {
      return this.prisma.meeting.update({
        where: { id: existingBinding.meetingId },
        data: {
          title,
          description: event.bodyPreview || null,
          startTime,
          endTime,
          timezone,
          location,
          status,
          type: 'HYBRID',
          creatorId: resolvedCreatorId,
          seriesId: meetingSeriesId || null,
          isSeriesMaster: normalizedType === 'seriesMaster',
          // v1.2 不回写 city/enforceCheckinMode —— 既有 meeting 已在 updateSeries 或本地编辑中维护；
          // Outlook 增量更新不应覆盖本地/系列级配置
        } as any,
      });
    }

    const created = await this.prisma.meeting.create({
      data: {
        title,
        description: event.bodyPreview || null,
        startTime,
        endTime,
        timezone,
        location,
        type: 'HYBRID',
        status,
        creatorId: resolvedCreatorId,
        seriesId: meetingSeriesId || null,
        isSeriesMaster: normalizedType === 'seriesMaster',
        // v1.2 新同步实例从 series 继承
        city: inheritedCity,
        enforceCheckinMode: inheritedEnforce,
      } as any,
    });

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

  private async ensureSeriesIfNeeded(
    event: GraphEventInfo,
    primaryMailboxId: string,
    fallbackCreatorId: string,
  ): Promise<string | null> {
    const eventType = this.getNormalizedEventType(event);
    if (eventType === 'single') {
      return null;
    }

    const seriesMasterId = eventType === 'seriesMaster' ? event.id : event.seriesMasterId;
    if (!seriesMasterId) {
      return null;
    }
    const seriesEvent = await this.resolveSeriesMetadataEvent(event, primaryMailboxId, seriesMasterId);

    const existingSeriesBinding = await this.prisma.outlookMeetingBinding.findFirst({
      where: {
        primaryMailboxId,
        graphEventId: seriesMasterId,
        meetingSeriesId: { not: null },
      },
      orderBy: { updatedAt: 'desc' },
    });
    if (existingSeriesBinding?.meetingSeriesId) {
      const timezone = this.resolveEventTimezone(seriesEvent);
      const { endDate, maxOccurrences } = this.resolveSeriesRange(seriesEvent, timezone);
      await this.prisma.meetingSeries.update({
        where: { id: existingSeriesBinding.meetingSeriesId },
        data: {
          isActive: true,
          title: seriesEvent.subject?.trim() || undefined,
          description: seriesEvent.bodyPreview || undefined,
          pattern: this.mapRecurrencePattern(seriesEvent.recurrence?.pattern?.type),
          frequency: Math.max(1, seriesEvent.recurrence?.pattern?.interval || 1),
          endDate: endDate ?? null,
          maxOccurrences: maxOccurrences ?? null,
          timezone,
          location: seriesEvent.location?.displayName || undefined,
        },
      }).catch(() => undefined);
      return existingSeriesBinding.meetingSeriesId;
    }

    const start = this.parseGraphDateTime(seriesEvent.start, seriesEvent);
    const timezone = this.resolveEventTimezone(seriesEvent);
    const { endDate, maxOccurrences } = this.resolveSeriesRange(seriesEvent, timezone);
    const resolvedCreatorId = await this.resolveMeetingCreatorId(seriesEvent, fallbackCreatorId);
    const series = await this.prisma.meetingSeries.create({
      data: {
        title: seriesEvent.subject?.trim() || 'Outlook Series',
        description: seriesEvent.bodyPreview || null,
        pattern: this.mapRecurrencePattern(seriesEvent.recurrence?.pattern?.type),
        frequency: Math.max(1, seriesEvent.recurrence?.pattern?.interval || 1),
        startDate: start,
        endDate,
        maxOccurrences,
        timezone,
        location: seriesEvent.location?.displayName || null,
        type: 'HYBRID',
        creatorId: resolvedCreatorId,
        isActive: true,
      },
    });

    await this.outlookSyncRepository.upsertBindingByGraphEvent(
      seriesMasterId,
      {
        primaryMailboxId,
        graphEventId: seriesMasterId,
        iCalUId: seriesEvent.iCalUId || seriesMasterId,
        graphSeriesMasterId: seriesMasterId,
        graphEventType: eventType,
        manageStatus: OutlookManageStatus.MANAGED,
        meetingSeriesId: series.id,
      },
      {
        meetingSeriesId: series.id,
        manageStatus: OutlookManageStatus.MANAGED,
      },
    );

    return series.id;
  }

  private async resolveManageRootEvent(mailboxEmail: string, event: GraphEventInfo): Promise<GraphEventInfo> {
    const eventType = this.getNormalizedEventType(event);
    if (eventType === 'seriesMaster') {
      return event;
    }
    if (!['occurrence', 'exception'].includes(eventType)) {
      return event;
    }
    if (!event.seriesMasterId) {
      return event;
    }
    const seriesMaster = await this.fetchEventById(mailboxEmail, event.seriesMasterId);
    return seriesMaster || event;
  }

  private async resolveSeriesMetadataEvent(
    event: GraphEventInfo,
    primaryMailboxId: string,
    seriesMasterId: string,
  ): Promise<GraphEventInfo> {
    const mailbox = await this.outlookSyncRepository.findMailboxById(primaryMailboxId);
    if (!mailbox) {
      return event;
    }
    const master = await this.fetchEventById(mailbox.mailboxEmail, seriesMasterId);
    return master
      ? {
        ...event,
        ...master,
      }
      : event;
  }

  private async bootstrapSeriesOccurrences(
    mailbox: { id: string; mailboxEmail: string },
    seriesMasterEvent: GraphEventInfo,
    seriesBinding: {
      id: string;
      graphEventType: string;
      graphEventId: string;
      primaryMailboxId: string;
      meetingSeriesId: string | null;
      meetingId?: string | null;
      syncFrom?: Date | null;
      syncMode?: OutlookBindingSyncMode | null;
      localOverrideReason?: string | null;
      localOverrideFields?: Prisma.JsonValue | null;
    },
    actorId: string,
  ): Promise<{ source: 'instances' | 'calendarViewFallback'; totalCandidates: number; syncedCount: number }> {
    const settings = await this.outlookSyncRepository.getOrCreateSettings();
    const startDate = new Date(Date.now() - settings.lookbackDays * 24 * 60 * 60 * 1000);
    const endDate = new Date(Date.now() + settings.lookaheadDays * 24 * 60 * 60 * 1000);
    let source: 'instances' | 'calendarViewFallback' = 'instances';
    let targetEvents: GraphEventInfo[] = [];
    try {
      const instanceEvents = await this.fetchSeriesInstanceEvents(
        mailbox.mailboxEmail,
        seriesMasterEvent.id,
        startDate,
        endDate,
      );
      targetEvents = instanceEvents.filter(
        (item) =>
          item.id
          && ['occurrence', 'exception'].includes(this.getNormalizedEventType(item))
          && item.seriesMasterId === seriesMasterEvent.id,
      );
    } catch (error) {
      source = 'calendarViewFallback';
      logger.warn(
        `Fetch series instances failed for ${mailbox.mailboxEmail}, fallback to calendarView: ${String(error)}`,
      );
      const events = await this.fetchCalendarViewEvents(
        mailbox.mailboxEmail,
        startDate,
        endDate,
        settings.deltaBatchSize,
      );
      targetEvents = events.filter(
        (item) =>
          item.id
          && ['occurrence', 'exception'].includes(this.getNormalizedEventType(item))
          && item.seriesMasterId === seriesMasterEvent.id,
      );
    }

    let syncedCount = 0;
    for (const event of targetEvents) {
      await this.upsertEventSnapshot(mailbox.id, event);
      if (!this.isEventInSyncWindow(event, seriesBinding.syncFrom || null)) {
        continue;
      }
      const excluded = await this.outlookSyncRepository.isSeriesOccurrenceExcluded(
        seriesBinding.id,
        event.id,
      );
      if (excluded) {
        continue;
      }
      const targetBinding = await this.ensureOccurrenceBindingForSeriesEvent(
        seriesBinding,
        event,
      );
      const meetingSeriesId = seriesBinding.meetingSeriesId
        || await this.ensureSeriesIfNeeded(event, mailbox.id, actorId);

      const eventStart = this.parseGraphDateTimeSafe(event.start, event);
      const eventEnd = this.parseGraphDateTimeSafe(event.end, event);
      const masterStart = this.parseGraphDateTimeSafe(seriesMasterEvent.start, seriesMasterEvent);
      const masterEnd = this.parseGraphDateTimeSafe(seriesMasterEvent.end, seriesMasterEvent);
      const shouldReuseSeriesMasterMeeting = Boolean(
        seriesBinding.meetingId
        && eventStart
        && eventEnd
        && masterStart
        && masterEnd
        && eventStart.getTime() === masterStart.getTime()
        && eventEnd.getTime() === masterEnd.getTime(),
      );

      let targetMeetingId: string;
      if (shouldReuseSeriesMasterMeeting && seriesBinding.meetingId) {
        targetMeetingId = seriesBinding.meetingId;
      } else {
        const meeting = await this.createOrUpdateMeetingFromEvent(event, actorId, meetingSeriesId);
        targetMeetingId = meeting.id;
      }

      if (!targetBinding.meetingId || targetBinding.meetingId !== targetMeetingId) {
        await this.outlookSyncRepository.updateBindingMeetingLink(targetBinding.id, {
          meetingId: targetMeetingId,
          meetingSeriesId: meetingSeriesId || null,
        });
      }
      const sourceSnapshot = await this.captureSourceVersionForBinding({
        bindingId: targetBinding.id,
        mailboxId: targetBinding.primaryMailboxId,
        event,
        versionSource: 'MANAGE_BOOTSTRAP',
        meetingId: targetMeetingId,
      });
      if (targetBinding.syncMode === OutlookBindingSyncMode.LOCKED_BY_LOCAL_EDIT) {
        await this.outlookSyncRepository.updateBindingSyncState(targetBinding.id, {
          lastSyncedAt: new Date(),
        });
        await this.logBindingEvent(targetBinding.id, targetBinding.primaryMailboxId, {
          eventType: 'SYNC_SKIPPED_LOCAL_OVERRIDE',
          resultStatus: 'INFO',
          message: 'Skipped applying Outlook update because meeting is locally maintained',
          payload: {
            ...sourceSnapshot,
            graphEventId: event.id,
            meetingId: targetMeetingId,
            reason: targetBinding.localOverrideReason || 'LOCAL_OVERRIDE',
            changedFields: targetBinding.localOverrideFields || [],
            triggerSource: source,
          },
        });
        continue;
      }
      await this.syncAttendees(targetMeetingId, event.attendees || [], event.organizer);
      await this.outlookSyncRepository.updateBindingSyncState(targetBinding.id, {
        manageStatus: OutlookManageStatus.MANAGED,
        cancellationSource: this.mapCancellationSource(event),
        lastSyncedAt: new Date(),
      });
      await this.logBindingEvent(targetBinding.id, targetBinding.primaryMailboxId, {
        eventType: 'SYNC_UPDATED',
        message: 'Graph event updated',
        payload: {
          graphEventId: event.id,
          meetingId: targetMeetingId,
          meetingStatus: null,
          triggerSource: source,
          ...sourceSnapshot,
        },
      });
      syncedCount += 1;
    }
    return {
      source,
      totalCandidates: targetEvents.length,
      syncedCount,
    };
  }

  private enqueueSeriesBootstrapTask(
    mailbox: { id: string; mailboxEmail: string },
    seriesMasterEvent: GraphEventInfo,
    seriesBinding: {
      id: string;
      graphEventType: string;
      graphEventId: string;
      primaryMailboxId: string;
      meetingSeriesId: string | null;
      meetingId?: string | null;
      syncFrom?: Date | null;
    },
    actorId: string,
  ) {
    const key = `${mailbox.id}::${seriesBinding.graphEventId}`;
    const running = this.seriesBootstrapQueue.get(key);
    if (running) {
      return;
    }
    const task = (async () => {
      await this.outlookSyncRepository.updateBindingSyncState(seriesBinding.id, {
        bootstrapStatus: OutlookBootstrapStatus.RUNNING,
        bootstrapError: null,
        bootstrapUpdatedAt: new Date(),
      });
      await this.logBindingEvent(seriesBinding.id, mailbox.id, {
        eventType: 'SERIES_BOOTSTRAP_STARTED',
        message: 'Series bootstrap started',
        payload: { graphEventId: seriesBinding.graphEventId },
      });
      const bootstrapResult = await this.bootstrapSeriesOccurrences(mailbox, seriesMasterEvent, seriesBinding, actorId);
      await this.outlookSyncRepository.updateBindingSyncState(seriesBinding.id, {
        bootstrapStatus: OutlookBootstrapStatus.SUCCEEDED,
        bootstrapError: null,
        bootstrapUpdatedAt: new Date(),
      });
      await this.logBindingEvent(seriesBinding.id, mailbox.id, {
        eventType: 'SERIES_BOOTSTRAP_SUCCEEDED',
        message: 'Series bootstrap completed',
        payload: {
          graphEventId: seriesBinding.graphEventId,
          source: bootstrapResult.source,
          totalCandidates: bootstrapResult.totalCandidates,
          syncedCount: bootstrapResult.syncedCount,
        },
      });
    })()
      .catch((error) => {
        logger.error(`Bootstrap series occurrences failed (${key}): ${String(error)}`);
        void this.outlookSyncRepository.updateBindingSyncState(seriesBinding.id, {
          bootstrapStatus: OutlookBootstrapStatus.FAILED,
          bootstrapError: String(error),
          bootstrapUpdatedAt: new Date(),
        });
        void this.logBindingEvent(seriesBinding.id, mailbox.id, {
          eventType: 'SERIES_BOOTSTRAP_FAILED',
          message: 'Series bootstrap failed',
          payload: {
            graphEventId: seriesBinding.graphEventId,
            error: String(error),
          },
        });
      })
      .finally(() => {
        this.seriesBootstrapQueue.delete(key);
      });
    this.seriesBootstrapQueue.set(key, task);
  }

  private async ensureOccurrenceBindingForSeriesEvent(
    binding: {
      id: string;
      graphEventType: string;
      graphEventId: string;
      primaryMailboxId: string;
      ownerUserId?: string | null;
      ownerEmail?: string | null;
      meetingId?: string | null;
      meetingSeriesId: string | null;
      syncFrom?: Date | null;
      syncMode?: OutlookBindingSyncMode | null;
      localOverrideReason?: string | null;
      localOverrideFields?: Prisma.JsonValue | null;
    },
    event: GraphEventInfo,
  ) {
    const isSeriesPropagation =
      binding.graphEventType === 'seriesMaster'
      && event.id !== binding.graphEventId
      && ['occurrence', 'exception'].includes(this.getNormalizedEventType(event))
      && event.seriesMasterId === binding.graphEventId;
    if (!isSeriesPropagation) {
      return binding;
    }

    return this.outlookSyncRepository.upsertBindingByGraphEvent(
      event.id,
      {
        primaryMailboxId: binding.primaryMailboxId,
        graphEventId: event.id,
        iCalUId: event.iCalUId || event.id,
        graphSeriesMasterId: binding.graphEventId,
        graphEventType: this.getNormalizedEventType(event),
        manageStatus: OutlookManageStatus.MANAGED,
        bootstrapStatus: null,
        bootstrapError: null,
        bootstrapUpdatedAt: new Date(),
        ownerUserId: binding.ownerUserId || null,
        ownerEmail: binding.ownerEmail || null,
        meetingSeriesId: binding.meetingSeriesId || null,
        syncFrom: binding.syncFrom || new Date(),
        syncMode: binding.syncMode || OutlookBindingSyncMode.AUTO,
        localOverrideReason: binding.localOverrideReason || null,
        localOverrideFields: binding.localOverrideFields ?? Prisma.JsonNull,
        cancellationSource: this.mapCancellationSource(event),
        lastSyncedAt: new Date(),
      },
      {
        iCalUId: event.iCalUId || event.id,
        graphSeriesMasterId: binding.graphEventId,
        graphEventType: this.getNormalizedEventType(event),
        manageStatus: OutlookManageStatus.MANAGED,
        bootstrapStatus: null,
        bootstrapError: null,
        bootstrapUpdatedAt: new Date(),
        ownerUserId: binding.ownerUserId || undefined,
        ownerEmail: binding.ownerEmail || undefined,
        meetingSeriesId: binding.meetingSeriesId || undefined,
        cancellationSource: this.mapCancellationSource(event),
        lastSyncedAt: new Date(),
      },
    );
  }

  private async populateBindingOwnerFallback<
    T extends {
      graphEventId: string;
      graphSeriesMasterId?: string | null;
      ownerUserId?: string | null;
      ownerEmail?: string | null;
    },
  >(items: T[]): Promise<T[]> {
    const seriesMasterIds = Array.from(
      new Set(
        items
          .filter(
            (item) =>
              !item.ownerUserId
              && !item.ownerEmail
              && item.graphSeriesMasterId
              && item.graphSeriesMasterId !== item.graphEventId,
          )
          .map((item) => item.graphSeriesMasterId as string),
      ),
    );
    if (!seriesMasterIds.length) {
      return items;
    }

    const seriesBindings = await this.outlookSyncRepository.findBindingsByGraphEventIds(seriesMasterIds);
    const ownerBySeriesMasterId = new Map(
      seriesBindings.map((item) => [item.graphEventId, { ownerUserId: item.ownerUserId, ownerEmail: item.ownerEmail }]),
    );

    return items.map((item) => {
      if (
        item.ownerUserId
        || item.ownerEmail
        || !item.graphSeriesMasterId
        || item.graphSeriesMasterId === item.graphEventId
      ) {
        return item;
      }
      const fallbackOwner = ownerBySeriesMasterId.get(item.graphSeriesMasterId);
      if (!fallbackOwner) {
        return item;
      }
      return {
        ...item,
        ownerUserId: fallbackOwner.ownerUserId || item.ownerUserId || null,
        ownerEmail: fallbackOwner.ownerEmail || item.ownerEmail || null,
      };
    });
  }

  private isEventInSyncWindow(event: GraphEventInfo, syncFrom?: Date | null) {
    if (!syncFrom) {
      return true;
    }
    const startTime = event.start?.dateTime
      ? this.parseGraphDateTimeSafe(event.start, event)
      : null;
    if (!startTime || Number.isNaN(startTime.getTime())) {
      return true;
    }
    return startTime.getTime() >= syncFrom.getTime();
  }

  private async syncAttendees(
    meetingId: string,
    attendees: GraphAttendeeInfo[],
    organizer?: { emailAddress?: { address?: string; name?: string } },
  ) {
    const settings = await this.outlookSyncRepository.getOrCreateSettings();
    const includeOrganizerAsAttendee = Boolean(settings.includeOrganizerAsAttendee);

    const attendeeEmailSet = new Set(
      attendees
        .map((item) => item.emailAddress?.address?.trim().toLowerCase())
        .filter((value): value is string => Boolean(value)),
    );
    const organizerEmail = organizer?.emailAddress?.address?.trim().toLowerCase();
    if (includeOrganizerAsAttendee && organizerEmail) {
      attendeeEmailSet.add(organizerEmail);
    }

    const attendeeEmails = Array.from(attendeeEmailSet);
    const users = attendeeEmails.length
      ? await this.prisma.user.findMany({
      where: {
        email: { in: attendeeEmails },
        deletedAt: null,
      },
      select: { id: true, email: true },
    })
      : [];

    const userMap = new Map(users.map((user) => [user.email.toLowerCase(), user]));
    // v1.3 自动入组：累积"邮箱不在 users 表"的真人邮箱，事务外异步触发 Graph 加组
    const emailsToAutoAdd = new Set<string>();

    // 系列层"参会人排除清单"过滤：若 meeting 属于某 series 且 user 在 exceptions 中，
    // 则跳过 upsert，让事务末的 deleteMany(notIn reservedUserIds) 把现存行也清掉，
    // 实现"系列删除参会人 → Outlook 同步不再回填"。
    const meetingForExclusion = await this.prisma.meeting.findUnique({
      where: { id: meetingId },
      select: { seriesId: true },
    });
    const seriesId = meetingForExclusion?.seriesId ?? null;
    const excludedUserIds = new Set<string>();
    if (seriesId) {
      const exceptions = await this.prisma.meetingSeriesAttendeeException.findMany({
        where: { seriesId },
        select: { userId: true },
      });
      for (const ex of exceptions) excludedUserIds.add(ex.userId);
    }

    await this.prisma.$transaction(async (tx) => {
      await tx.meetingExternalAttendee.deleteMany({
        where: { meetingId },
      });
      const internalUserIds = new Set<string>();
      for (const attendee of attendees) {
        const email = attendee.emailAddress?.address?.trim().toLowerCase();
        if (!email) {
          continue;
        }

        await tx.meetingExternalAttendee.upsert({
          where: {
            meetingId_email_sourceRole: {
              meetingId,
              email,
              sourceRole: 'ATTENDEE',
            },
          },
          create: {
            meetingId,
            email,
            displayName: attendee.emailAddress?.name || null,
            attendeeType: attendee.type || null,
            response: attendee.status?.response || null,
            sourceRole: 'ATTENDEE',
          },
          update: {
            displayName: attendee.emailAddress?.name || null,
            attendeeType: attendee.type || null,
            response: attendee.status?.response || null,
          },
        });

        const user = userMap.get(email);
        if (!user) {
          emailsToAutoAdd.add(email);
          continue;
        }

        // 排除清单命中：不 upsert、不进 reservedUserIds，让事务末 deleteMany 清掉现存行。
        if (excludedUserIds.has(user.id)) {
          continue;
        }

        internalUserIds.add(user.id);

        const role = attendee.type === 'optional'
          ? AttendeeRole.OPTIONAL_ATTENDEE
          : AttendeeRole.REGULAR_ATTENDEE;

        await tx.meetingRequiredAttendee.upsert({
          where: {
            meetingId_userId: {
              meetingId,
              userId: user.id,
            },
          },
          create: {
            meetingId,
            userId: user.id,
            role,
          },
          update: { role },
        });

        await tx.meetingAttendance.upsert({
          where: {
            userId_meetingId: {
              userId: user.id,
              meetingId,
            },
          },
          create: {
            userId: user.id,
            meetingId,
            status: 'NOT_CHECKED_IN',
          },
          update: {},
        });
      }

      if (organizerEmail) {
        await tx.meetingExternalAttendee.upsert({
          where: {
            meetingId_email_sourceRole: {
              meetingId,
              email: organizerEmail,
              sourceRole: 'ORGANIZER',
            },
          },
          create: {
            meetingId,
            email: organizerEmail,
            displayName: organizer?.emailAddress?.name || null,
            sourceRole: 'ORGANIZER',
          },
          update: {
            displayName: organizer?.emailAddress?.name || null,
          },
        });

        if (includeOrganizerAsAttendee) {
          const organizerUser = userMap.get(organizerEmail);
          if (organizerUser && !excludedUserIds.has(organizerUser.id)) {
            internalUserIds.add(organizerUser.id);
            await tx.meetingRequiredAttendee.upsert({
              where: {
                meetingId_userId: {
                  meetingId,
                  userId: organizerUser.id,
                },
              },
              create: {
                meetingId,
                userId: organizerUser.id,
                role: AttendeeRole.OPTIONAL_ATTENDEE,
              },
              update: {
                role: AttendeeRole.OPTIONAL_ATTENDEE,
              },
            });
            await tx.meetingAttendance.upsert({
              where: {
                userId_meetingId: {
                  userId: organizerUser.id,
                  meetingId,
                },
              },
              create: {
                userId: organizerUser.id,
                meetingId,
                status: 'NOT_CHECKED_IN',
              },
              update: {},
            });
          }
        }
      }

      if (internalUserIds.size > 0) {
        const reservedUserIds = Array.from(internalUserIds);
        await tx.meetingRequiredAttendee.deleteMany({
          where: {
            meetingId,
            userId: { notIn: reservedUserIds },
          },
        });
        await tx.meetingAttendance.deleteMany({
          where: {
            meetingId,
            userId: { notIn: reservedUserIds },
          },
        });
      } else {
        await tx.meetingRequiredAttendee.deleteMany({
          where: { meetingId },
        });
        await tx.meetingAttendance.deleteMany({
          where: { meetingId },
        });
      }
    });

    // v1.3 事务外触发自动入组（fire-and-forget；service 内部限并发 + dedup + audit）
    if (emailsToAutoAdd.size > 0) {
      this.attendeeAutoAddService.fireAutoAddBatched(emailsToAutoAdd).catch((error) => {
        logger.warn(`[outlook-auto-add] unexpected escape from fireAutoAddBatched: ${String(error)}`);
      });
    }
  }

  private deriveMeetingStatus(
    event: GraphEventInfo,
    start: Date,
    end: Date,
    now: Date,
  ): MeetingStatus {
    if (event.isCancelled) {
      return MeetingStatus.CANCELLED;
    }
    if (now > end) {
      return MeetingStatus.COMPLETED;
    }
    if (now >= start && now <= end) {
      return MeetingStatus.IN_PROGRESS;
    }
    return MeetingStatus.SCHEDULED;
  }

  private parseGraphDateTime(value: GraphDateTimeInfo | undefined, event?: GraphEventInfo): Date {
    if (!value?.dateTime) {
      throw new MeetingAttendanceError(400, 'Outlook event datetime missing');
    }
    const raw = value.dateTime.trim();
    const hasOffset = /[zZ]$|[+-]\d{2}:\d{2}$/.test(raw);
    if (hasOffset) {
      return new Date(raw);
    }
    const timezone = this.resolveDateTimeTimezone(value, event);
    return convertToUTC(raw, timezone);
  }

  private parseGraphDateTimeSafe(value: GraphDateTimeInfo | undefined, event?: GraphEventInfo): Date | null {
    try {
      return this.parseGraphDateTime(value, event);
    } catch {
      return null;
    }
  }

  private parseGraphDateTimeToMs(raw?: string | null, timezone?: string | null): number {
    if (!raw) {
      return 0;
    }
    try {
      const hasOffset = /[zZ]$|[+-]\d{2}:\d{2}$/.test(raw);
      if (hasOffset) {
        const direct = new Date(raw).getTime();
        return Number.isNaN(direct) ? 0 : direct;
      }
      return convertToUTC(raw, timezone || DEFAULT_OUTLOOK_SYNC_TIMEZONE).getTime();
    } catch {
      const fallback = new Date(raw).getTime();
      return Number.isNaN(fallback) ? 0 : fallback;
    }
  }

  private resolveDateTimeTimezone(value: GraphDateTimeInfo, event?: GraphEventInfo): string {
    return normalizeToIanaTimezone(
      value.timeZone
      || event?.start?.timeZone
      || event?.end?.timeZone
      || event?.recurrence?.range?.recurrenceTimeZone
      || event?.originalStartTimeZone
      || event?.originalEndTimeZone
      ,
      DEFAULT_OUTLOOK_SYNC_TIMEZONE,
    );
  }

  private resolveEventTimezone(event: GraphEventInfo): string {
    const recurrenceTz = normalizeToIanaTimezone(
      event.recurrence?.range?.recurrenceTimeZone,
      '',
    );
    if (recurrenceTz) {
      return recurrenceTz;
    }

    const originalStartTz = normalizeToIanaTimezone(event.originalStartTimeZone, '');
    if (originalStartTz) {
      return originalStartTz;
    }

    const originalEndTz = normalizeToIanaTimezone(event.originalEndTimeZone, '');
    if (originalEndTz) {
      return originalEndTz;
    }

    return normalizeToIanaTimezone(
      event.start?.timeZone
      || event.end?.timeZone,
      DEFAULT_OUTLOOK_SYNC_TIMEZONE,
    );
  }

  private getNormalizedEventType(event: GraphEventInfo): 'single' | 'seriesMaster' | 'occurrence' | 'exception' {
    if (event.type === 'seriesMaster') {
      return 'seriesMaster';
    }
    if (event.type === 'exception') {
      return 'exception';
    }
    if (event.type === 'occurrence') {
      return 'occurrence';
    }
    if (event.seriesMasterId) {
      return 'occurrence';
    }
    return 'single';
  }

  private toCandidateItem(
    event: GraphEventInfo,
    context: {
      bindingMap: Map<string, any>;
      seriesBindingMap: Map<string, any>;
      actorId: string;
      fallbackMailboxId: string;
    },
  ) {
    const normalizedType = this.getNormalizedEventType(event);
    const binding = context.bindingMap.get(event.id);
    const seriesMasterId = event.seriesMasterId
      || binding?.graphSeriesMasterId
      || null;
    const seriesBinding = seriesMasterId
      ? context.seriesBindingMap.get(seriesMasterId)
      : null;
    const isExcludedBySeries = Boolean(
      seriesBinding?.occurrenceExclusions?.some(
        (item: any) => item.occurrenceGraphEventId === event.id,
      ),
    );
    const managed = binding?.manageStatus === OutlookManageStatus.MANAGED;
    const effectiveSyncFrom = binding?.syncFrom || seriesBinding?.syncFrom || null;
    const inSeriesSyncWindow = Boolean(
      seriesBinding && this.isEventInSyncWindow(event, seriesBinding.syncFrom || null),
    );
    const managedBySeries = Boolean(
      normalizedType !== 'seriesMaster'
      && (
        (binding?.graphSeriesMasterId && binding.graphSeriesMasterId !== binding.graphEventId)
        || inSeriesSyncWindow
      ),
    );
    const participants = new Set<string>(
      [
        event.organizer?.emailAddress?.address?.toLowerCase(),
        ...(event.attendees || []).map((attendee) => attendee.emailAddress?.address?.toLowerCase()),
      ].filter((value): value is string => Boolean(value)),
    );
    const ownerEmail = binding?.ownerEmail || seriesBinding?.ownerEmail || null;
    const ownerInParticipants = ownerEmail ? participants.has(ownerEmail.toLowerCase()) : true;
    const managedByOther = Boolean(
      (binding?.ownerUserId || seriesBinding?.ownerUserId)
      && (binding?.ownerUserId || seriesBinding?.ownerUserId) !== context.actorId,
    );
    const timezone = this.resolveEventTimezone(event);
    const parsedStartTime = this.parseGraphDateTimeSafe(event.start, event);
    const parsedEndTime = this.parseGraphDateTimeSafe(event.end, event);
    return {
      graphEventId: event.id,
      iCalUId: event.iCalUId || '',
      title: event.subject || '(No Subject)',
      startTime: parsedStartTime ? parsedStartTime.toISOString() : null,
      endTime: parsedEndTime ? parsedEndTime.toISOString() : null,
      timezone,
      eventType: normalizedType,
      isCancelled: this.isEffectivelyCancelledEvent(event),
      seriesMasterId: event.seriesMasterId || null,
      organizerEmail: event.organizer?.emailAddress?.address || null,
      managed: managed || managedBySeries,
      managedBySeries,
      effectiveSyncFrom,
      isExcludedBySeries,
      bindingId: binding?.id || seriesBinding?.id || null,
      seriesBindingId: seriesBinding?.id || null,
      manageStatus: binding?.manageStatus || (managedBySeries ? 'MANAGED_BY_SERIES' : 'PENDING_SELECTION'),
      bootstrapStatus: binding?.bootstrapStatus || seriesBinding?.bootstrapStatus || null,
      bootstrapError: binding?.bootstrapError || seriesBinding?.bootstrapError || null,
      primaryMailboxId: binding?.primaryMailboxId || seriesBinding?.primaryMailboxId || context.fallbackMailboxId,
      ownerUserId: binding?.ownerUserId || seriesBinding?.ownerUserId || null,
      ownerEmail,
      managedByOther,
      ownerInParticipants,
      needsTakeover: managed && managedByOther,
      needsOwnerAttention: managed && managedByOther && !ownerInParticipants,
    };
  }

  private toCandidateItemFromSnapshot(
    snapshot: {
      graphEventId: string;
      iCalUId: string;
      title: string;
      startTime: Date | null;
      endTime: Date | null;
      timezone: string;
      eventType: string;
      isCancelled: boolean;
      seriesMasterId: string | null;
      organizerEmail: string | null;
      rawPayload: Prisma.JsonValue | null;
    },
    context: {
      bindingMap: Map<string, any>;
      seriesBindingMap: Map<string, any>;
      actorId: string;
      fallbackMailboxId: string;
    },
  ) {
    const binding = context.bindingMap.get(snapshot.graphEventId);
    const seriesMasterId = snapshot.seriesMasterId
      || binding?.graphSeriesMasterId
      || null;
    const seriesBinding = seriesMasterId
      ? context.seriesBindingMap.get(seriesMasterId)
      : null;
    const isExcludedBySeries = Boolean(
      seriesBinding?.occurrenceExclusions?.some(
        (item: any) => item.occurrenceGraphEventId === snapshot.graphEventId,
      ),
    );
    const managed = binding?.manageStatus === OutlookManageStatus.MANAGED;
    const effectiveSyncFrom = binding?.syncFrom || seriesBinding?.syncFrom || null;
    const inSeriesSyncWindow = Boolean(
      seriesBinding && this.isDateInSyncWindow(snapshot.startTime, seriesBinding.syncFrom || null),
    );
    const managedBySeries = Boolean(
      snapshot.eventType !== 'seriesMaster'
      && (
        (binding?.graphSeriesMasterId && binding.graphSeriesMasterId !== binding.graphEventId)
        || inSeriesSyncWindow
      ),
    );
    const raw = (snapshot.rawPayload && typeof snapshot.rawPayload === 'object')
      ? (snapshot.rawPayload as any)
      : {};
    const participantCandidates = [
      snapshot.organizerEmail?.toLowerCase(),
      raw?.organizer?.emailAddress?.address?.toLowerCase(),
      ...(((raw?.attendees || []) as any[]).map((attendee) => attendee?.emailAddress?.address?.toLowerCase())),
    ];
    const participants = new Set<string>(
      participantCandidates.filter((value): value is string => Boolean(value)),
    );
    const ownerEmail = binding?.ownerEmail || seriesBinding?.ownerEmail || null;
    const ownerInParticipants = ownerEmail ? participants.has(ownerEmail.toLowerCase()) : true;
    const managedByOther = Boolean(
      (binding?.ownerUserId || seriesBinding?.ownerUserId)
      && (binding?.ownerUserId || seriesBinding?.ownerUserId) !== context.actorId,
    );

    return {
      graphEventId: snapshot.graphEventId,
      iCalUId: snapshot.iCalUId || snapshot.graphEventId,
      title: snapshot.title || '(No Subject)',
      startTime: snapshot.startTime ? snapshot.startTime.toISOString() : null,
      endTime: snapshot.endTime ? snapshot.endTime.toISOString() : null,
      timezone: snapshot.timezone || DEFAULT_OUTLOOK_SYNC_TIMEZONE,
      eventType: snapshot.eventType as 'single' | 'seriesMaster' | 'occurrence' | 'exception',
      isCancelled: snapshot.isCancelled,
      seriesMasterId: snapshot.seriesMasterId || null,
      organizerEmail: snapshot.organizerEmail || null,
      managed: managed || managedBySeries,
      managedBySeries,
      effectiveSyncFrom,
      isExcludedBySeries,
      bindingId: binding?.id || seriesBinding?.id || null,
      seriesBindingId: seriesBinding?.id || null,
      manageStatus: binding?.manageStatus || (managedBySeries ? 'MANAGED_BY_SERIES' : 'PENDING_SELECTION'),
      bootstrapStatus: binding?.bootstrapStatus || seriesBinding?.bootstrapStatus || null,
      bootstrapError: binding?.bootstrapError || seriesBinding?.bootstrapError || null,
      primaryMailboxId: binding?.primaryMailboxId || seriesBinding?.primaryMailboxId || context.fallbackMailboxId,
      ownerUserId: binding?.ownerUserId || seriesBinding?.ownerUserId || null,
      ownerEmail,
      managedByOther,
      ownerInParticipants,
      needsTakeover: managed && managedByOther,
      needsOwnerAttention: managed && managedByOther && !ownerInParticipants,
    };
  }

  private isDateInSyncWindow(startTime: Date | null, syncFrom: Date | null) {
    if (!startTime || !syncFrom) {
      return true;
    }
    return startTime.getTime() >= syncFrom.getTime();
  }

  private parseOptionalDate(value: string | undefined, message: string): Date | undefined {
    if (!value) {
      return undefined;
    }
    const parsed = new Date(value);
    if (Number.isNaN(parsed.getTime())) {
      throw new MeetingAttendanceError(400, message);
    }
    return parsed;
  }

  private async upsertEventSnapshot(mailboxId: string, event: GraphEventInfo) {
    if (!event?.id) {
      return;
    }
    const startTime = this.parseGraphDateTimeSafe(event.start, event);
    const endTime = this.parseGraphDateTimeSafe(event.end, event);
    await this.prisma.outlookEventSnapshot.upsert({
      where: {
        mailboxId_graphEventId: {
          mailboxId,
          graphEventId: event.id,
        },
      },
      create: {
        mailboxId,
        graphEventId: event.id,
        iCalUId: event.iCalUId || event.id,
        title: event.subject || '(No Subject)',
        startTime,
        endTime,
        timezone: this.resolveEventTimezone(event),
        eventType: this.getNormalizedEventType(event),
        isCancelled: this.isEffectivelyCancelledEvent(event),
        seriesMasterId: event.seriesMasterId || null,
        organizerEmail: event.organizer?.emailAddress?.address || null,
        lastModifiedAt: event.lastModifiedDateTime ? new Date(event.lastModifiedDateTime) : null,
        rawPayload: event as any,
      },
      update: {
        iCalUId: event.iCalUId || event.id,
        title: event.subject || '(No Subject)',
        startTime,
        endTime,
        timezone: this.resolveEventTimezone(event),
        eventType: this.getNormalizedEventType(event),
        isCancelled: this.isEffectivelyCancelledEvent(event),
        seriesMasterId: event.seriesMasterId || null,
        organizerEmail: event.organizer?.emailAddress?.address || null,
        lastModifiedAt: event.lastModifiedDateTime ? new Date(event.lastModifiedDateTime) : null,
        rawPayload: event as any,
      },
    });
  }

  private async deleteEventSnapshot(mailboxId: string, graphEventId: string) {
    await this.prisma.outlookEventSnapshot.deleteMany({
      where: {
        mailboxId,
        graphEventId,
      },
    });
  }

  private isEffectivelyCancelledEvent(event: Pick<GraphEventInfo, 'isCancelled' | 'subject'>) {
    if (Boolean(event.isCancelled)) {
      return true;
    }
    const normalizedSubject = (event.subject || '').trim().toLowerCase();
    if (!normalizedSubject) {
      return false;
    }
    return (
      normalizedSubject.startsWith('canceled:')
      || normalizedSubject.startsWith('cancelled:')
      || normalizedSubject.startsWith('已取消')
      || normalizedSubject.startsWith('取消:')
    );
  }

  private escapeCsv(value: string): string {
    return `"${value.replace(/"/g, '""')}"`;
  }

  private formatCsvDate(value: Date | string): string {
    const date = value instanceof Date ? value : new Date(value);
    if (Number.isNaN(date.getTime())) {
      return '';
    }
    return date.toISOString();
  }

  private mapRecurrencePattern(pattern?: string): RecurrencePattern {
    switch (pattern) {
      case 'daily':
        return RecurrencePattern.DAILY;
      case 'weekly':
        return RecurrencePattern.WEEKLY;
      case 'absoluteMonthly':
      case 'relativeMonthly':
        return RecurrencePattern.MONTHLY;
      case 'absoluteYearly':
      case 'relativeYearly':
        return RecurrencePattern.YEARLY;
      default:
        return RecurrencePattern.WEEKLY;
    }
  }

  private resolveSeriesRange(
    event: GraphEventInfo,
    timezone: string,
  ): { endDate: Date | null; maxOccurrences: number | null } {
    const range = event.recurrence?.range;
    if (!range) {
      return { endDate: null, maxOccurrences: null };
    }

    const endDateRaw = range.endDate?.trim();
    const endDate = endDateRaw
      ? convertToUTC(`${endDateRaw}T23:59:59`, timezone)
      : null;
    const maxOccurrences = !endDateRaw
      && range.type === 'numbered'
      && Number.isFinite(range.numberOfOccurrences as number)
      ? Math.max(1, Number(range.numberOfOccurrences))
      : null;

    return { endDate, maxOccurrences };
  }

  private mapCancellationSource(event: GraphEventInfo): OutlookCancellationSource | null {
    if (event.isCancelled) {
      return OutlookCancellationSource.CANCELLED_BY_ORGANIZER;
    }
    return null;
  }

  private getGraphClient(): Client {
    if (this.graphClient) {
      return this.graphClient;
    }

    const tenantId = this.configService.get<string>('AZURE_TENANT_ID');
    const clientId = this.configService.get<string>('AZURE_CLIENT_ID');
    const clientSecret = this.configService.get<string>('AZURE_CLIENT_SECRET');
    if (!tenantId || !clientId || !clientSecret) {
      throw new MeetingAttendanceError(500, 'Azure credentials are not fully configured');
    }

    const credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
    const authProvider = new TokenCredentialAuthenticationProvider(credential, {
      scopes: ['https://graph.microsoft.com/.default'],
    });

    this.graphClient = Client.initWithMiddleware({ authProvider });
    return this.graphClient;
  }

  private getWebhookUrl(pathname: string) {
    const baseUrl = process.env.MEETING_ATTENDANCE_BASE_URL
      || process.env.FRONTEND_URL
      || process.env.APP_PUBLIC_BASE_URL;
    if (!baseUrl) {
      throw new MeetingAttendanceError(
        500,
        'MEETING_ATTENDANCE_BASE_URL or FRONTEND_URL or APP_PUBLIC_BASE_URL is required',
      );
    }

    const apiPrefix = process.env.API_PREFIX || '/api/v1';
    return `${baseUrl}${apiPrefix}/meeting-attendance/integrations/outlook/webhooks/${pathname}`;
  }

  private canCreateWebhookSubscription(): boolean {
    try {
      const notificationsUrl = new URL(this.getWebhookUrl('notifications'));
      if (notificationsUrl.protocol !== 'https:') {
        return false;
      }
      const host = notificationsUrl.hostname.toLowerCase();
      if (host === 'localhost' || host === '127.0.0.1' || host === '::1') {
        return false;
      }
      return true;
    } catch {
      return false;
    }
  }

  private async createGraphSubscription(mailboxEmail: string) {
    const clientState = this.configService.get<string>('GRAPH_NOTIFICATION_CLIENT_STATE');
    if (!clientState) {
      throw new MeetingAttendanceError(500, 'GRAPH_NOTIFICATION_CLIENT_STATE is required');
    }

    const client = this.getGraphClient();
    const expirationAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
    const payload = {
      changeType: 'created,updated,deleted',
      notificationUrl: this.getWebhookUrl('notifications'),
      lifecycleNotificationUrl: this.getWebhookUrl('lifecycle'),
      resource: `/users/${mailboxEmail}/events`,
      expirationDateTime: expirationAt.toISOString(),
      clientState,
    };

    return client.api('/subscriptions').post(payload);
  }

  private async renewGraphSubscription(graphSubscriptionId: string) {
    const client = this.getGraphClient();
    const expirationAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
    return client.api(`/subscriptions/${graphSubscriptionId}`).patch({
      expirationDateTime: expirationAt.toISOString(),
    });
  }

  private async deleteGraphSubscription(graphSubscriptionId: string) {
    const client = this.getGraphClient();
    await client.api(`/subscriptions/${graphSubscriptionId}`).delete();
  }

  private async fetchCalendarViewEvents(
    mailboxEmail: string,
    start: Date,
    end: Date,
    top: number,
  ): Promise<GraphEventInfo[]> {
    const client = this.getGraphClient();
    const pageSize = Math.max(20, Math.min(top, 100));
    const maxFetch = Math.max(pageSize * 2, Math.min(top * 4, 2000));
    const selectFields =
      'id,iCalUId,subject,bodyPreview,start,end,location,type,isCancelled,seriesMasterId,attendees,organizer,lastModifiedDateTime,webLink,recurrence,originalStartTimeZone,originalEndTimeZone';
    let nextLink: string | null = `/users/${mailboxEmail}/calendarView`;
    let calendarViewEvents: GraphEventInfo[] = [];

    while (nextLink && calendarViewEvents.length < maxFetch) {
      const request = client.api(nextLink);
      if (!nextLink.startsWith('http')) {
        request.query({
          startDateTime: start.toISOString(),
          endDateTime: end.toISOString(),
          $top: String(pageSize),
          $select: selectFields,
        });
      }
      const response = await request.get();
      const pageEvents = (response?.value || []) as GraphEventInfo[];
      calendarViewEvents = [...calendarViewEvents, ...pageEvents];
      nextLink = response?.['@odata.nextLink'] || null;
    }

    // calendarView 通常返回 occurrence/exception，补拉 seriesMaster 避免系列会议在候选中缺失。
    let seriesMasters: GraphEventInfo[] = [];
    try {
      seriesMasters = await this.fetchSeriesMasterEvents(mailboxEmail, top);
    } catch (error) {
      logger.warn(`Failed to fetch series masters for mailbox ${mailboxEmail}: ${String(error)}`);
    }

    if (seriesMasters.length === 0) {
      try {
        const masterIds = Array.from(
          new Set(
            calendarViewEvents
              .map((event) => event.seriesMasterId)
              .filter((value): value is string => Boolean(value)),
          ),
        );
        if (masterIds.length > 0) {
          const mastersByIds = await this.fetchSeriesMasterEventsByIds(mailboxEmail, masterIds);
          seriesMasters = mastersByIds;
        }
      } catch (error) {
        logger.warn(`Failed to fetch series masters by ids for mailbox ${mailboxEmail}: ${String(error)}`);
      }
    }

    const deduped = new Map<string, GraphEventInfo>();
    for (const event of [...calendarViewEvents, ...seriesMasters]) {
      if (!event?.id) {
        continue;
      }
      deduped.set(event.id, event);
    }
    return Array.from(deduped.values());
  }

  private async fetchManageEvent(mailboxEmail: string, graphEventId: string): Promise<GraphEventInfo | null> {
    const settings = await this.outlookSyncRepository.getOrCreateSettings();
    const start = new Date(Date.now() - settings.lookbackDays * 24 * 60 * 60 * 1000);
    const end = new Date(Date.now() + settings.lookaheadDays * 24 * 60 * 60 * 1000);
    try {
      const events = await this.fetchCalendarViewEvents(
        mailboxEmail,
        start,
        end,
        Math.max(Math.min(settings.deltaBatchSize, 120), 80),
      );
      const matched = events.find((event) => event.id === graphEventId);
      if (matched) {
        return matched;
      }
    } catch (error) {
      logger.warn(`Failed to fetch manage event from calendarView: ${String(error)}`);
    }
    return this.fetchEventById(mailboxEmail, graphEventId);
  }

  private async fetchSeriesInstanceEvents(
    mailboxEmail: string,
    seriesMasterId: string,
    start: Date,
    end: Date,
  ): Promise<GraphEventInfo[]> {
    const client = this.getGraphClient();
    const selectFields =
      'id,iCalUId,subject,bodyPreview,start,end,location,type,isCancelled,seriesMasterId,attendees,organizer,lastModifiedDateTime,webLink,recurrence,originalStartTimeZone,originalEndTimeZone';
    let nextLink: string | null = `/users/${mailboxEmail}/events/${seriesMasterId}/instances`;
    const items: GraphEventInfo[] = [];
    const maxFetch = 2000;

    while (nextLink && items.length < maxFetch) {
      const request = client.api(nextLink);
      if (!nextLink.startsWith('http')) {
        request.query({
          startDateTime: start.toISOString(),
          endDateTime: end.toISOString(),
          $top: '200',
          $select: selectFields,
        });
      }
      const response = await request.get();
      items.push(...((response?.value || []) as GraphEventInfo[]));
      nextLink = response?.['@odata.nextLink'] || null;
    }

    return items;
  }

  private async fetchSeriesMasterEvents(mailboxEmail: string, top: number): Promise<GraphEventInfo[]> {
    const client = this.getGraphClient();
    const response = await client
      .api(`/users/${mailboxEmail}/events`)
      .query({
        $top: String(Math.max(20, Math.min(top, 1000))),
        $filter: "type eq 'seriesMaster'",
        $select:
          'id,iCalUId,subject,bodyPreview,start,end,location,type,isCancelled,seriesMasterId,attendees,organizer,lastModifiedDateTime,webLink,recurrence,originalStartTimeZone,originalEndTimeZone',
      })
      .get();
    return (response?.value || []) as GraphEventInfo[];
  }

  private async fetchSeriesMasterEventsByIds(
    mailboxEmail: string,
    ids: string[],
  ): Promise<GraphEventInfo[]> {
    const results: GraphEventInfo[] = [];
    const sliceSize = 20;
    for (let i = 0; i < ids.length; i += sliceSize) {
      const group = ids.slice(i, i + sliceSize);
      const fetched = await Promise.all(
        group.map(async (id) => {
          const event = await this.fetchEventById(mailboxEmail, id);
          if (!event || event.type !== 'seriesMaster') {
            return null;
          }
          return {
            id: event.id,
            iCalUId: event.iCalUId,
            subject: event.subject,
            start: event.start,
            end: event.end,
            type: event.type,
            isCancelled: event.isCancelled,
            seriesMasterId: event.seriesMasterId,
            attendees: event.attendees,
            organizer: event.organizer,
            lastModifiedDateTime: event.lastModifiedDateTime,
            webLink: event.webLink,
            recurrence: event.recurrence,
            originalStartTimeZone: event.originalStartTimeZone,
            originalEndTimeZone: event.originalEndTimeZone,
          } as GraphEventInfo;
        }),
      );
      results.push(...fetched.filter((item): item is GraphEventInfo => Boolean(item)));
    }
    return results;
  }

  private async fetchEventById(mailboxEmail: string, graphEventId: string): Promise<GraphEventInfo | null> {
    const client = this.getGraphClient();
    const encodedEventId = encodeURIComponent(graphEventId);
    try {
      return await client
        .api(`/users/${mailboxEmail}/events/${encodedEventId}`)
        .select(
          'id,iCalUId,subject,bodyPreview,start,end,location,isCancelled,type,seriesMasterId,attendees,organizer,recurrence,originalStartTimeZone,originalEndTimeZone',
        )
        .get();
    } catch (error: any) {
      if (error?.statusCode === 404) {
        return null;
      }
      throw error;
    }
  }

  private async getFallbackCreatorId() {
    const admin = await this.prisma.user.findFirst({
      where: { deletedAt: null },
      orderBy: { createdAt: 'asc' },
      select: { id: true },
    });
    if (!admin) {
      throw new MeetingAttendanceError(500, 'No available user found as meeting creator');
    }
    return admin.id;
  }

  private async resolveMeetingCreatorId(event: GraphEventInfo, fallbackCreatorId?: string) {
    const organizerEmail = event.organizer?.emailAddress?.address?.trim().toLowerCase();
    if (organizerEmail) {
      const creator = await this.prisma.user.findFirst({
        where: {
          email: organizerEmail,
          deletedAt: null,
        },
        select: { id: true },
      });
      if (creator?.id) {
        return creator.id;
      }
    }

    if (fallbackCreatorId) {
      return fallbackCreatorId;
    }
    return this.getFallbackCreatorId();
  }

  private async hydrateEventForSync(mailboxEmail: string, event: GraphEventInfo): Promise<GraphEventInfo> {
    const needHydrate =
      !event.attendees
      || !event.organizer
      || !event.start
      || !event.end
      || !event.subject
      || !event.location;

    if (!needHydrate) {
      return event;
    }

    const latest = await this.fetchEventById(mailboxEmail, event.id);
    if (!latest) {
      return event;
    }

    return {
      ...latest,
      id: latest.id || event.id,
      iCalUId: latest.iCalUId || event.iCalUId,
      type: latest.type || event.type,
      seriesMasterId: latest.seriesMasterId || event.seriesMasterId,
    };
  }

  private async captureSourceVersionForBinding(params: {
    bindingId: string;
    mailboxId: string;
    event: GraphEventInfo;
    versionSource: 'WEBHOOK_DELTA' | 'RECONCILE' | 'MANAGE_BOOTSTRAP' | 'MANUAL_REFRESH';
    meetingId?: string | null;
  }): Promise<{
    sourceVersionId: string | null;
    diffId: string | null;
    graphLastModifiedAt: string | null;
    fetchedAt: string;
    hasContentChange: boolean;
    changedFields: string[];
    changeSummary: Record<string, unknown> | null;
    attendeesAdded: unknown[] | null;
    attendeesRemoved: unknown[] | null;
    attendeesResponseChanged: unknown[] | null;
  }> {
    const normalizedPayload = this.normalizeEventPayload(params.event);
    const payloadHash = createHash('sha256')
      .update(JSON.stringify(normalizedPayload))
      .digest('hex');
    const latestVersion = await this.outlookSyncRepository.getLatestSourceVersion(params.bindingId);
    const graphLastModifiedAt = params.event.lastModifiedDateTime
      ? new Date(params.event.lastModifiedDateTime)
      : null;
    const fetchedAt = new Date();

    if (latestVersion?.payloadHash === payloadHash) {
      const payload = latestVersion.normalizedPayload as Record<string, any> | null;
      return {
        sourceVersionId: latestVersion.id,
        diffId: null,
        graphLastModifiedAt: graphLastModifiedAt?.toISOString() || latestVersion.graphLastModifiedAt?.toISOString() || null,
        fetchedAt: fetchedAt.toISOString(),
        hasContentChange: false,
        changedFields: [],
        changeSummary: payload
          ? {
            counts: {
              graphAttendees: latestVersion.attendeesCount,
              internalMatched: await this.countInternalMatchedAttendees(params.event),
              meetingRequired: await this.countMeetingRequiredAttendees(params.meetingId || null),
            },
          }
          : null,
        attendeesAdded: null,
        attendeesRemoved: null,
        attendeesResponseChanged: null,
      };
    }

    const sourceVersion = await this.outlookSyncRepository.createSourceVersion({
      bindingId: params.bindingId,
      mailboxId: params.mailboxId,
      graphEventId: params.event.id,
      graphSeriesMasterId: params.event.seriesMasterId || null,
      graphEventType: this.getNormalizedEventType(params.event),
      versionSource: params.versionSource,
      graphLastModifiedAt,
      fetchedAt,
      payloadHash,
      attendeesCount: normalizedPayload.attendees.length,
      attendeesRequiredCount: normalizedPayload.attendees.filter((item: any) => item.type === 'required').length,
      attendeesOptionalCount: normalizedPayload.attendees.filter((item: any) => item.type === 'optional').length,
      attendeesResourceCount: normalizedPayload.attendees.filter((item: any) => item.type === 'resource').length,
      organizerEmail: normalizedPayload.organizer?.email || null,
      startTime: normalizedPayload.startTime ? new Date(normalizedPayload.startTime) : null,
      endTime: normalizedPayload.endTime ? new Date(normalizedPayload.endTime) : null,
      isCancelled: Boolean(normalizedPayload.isCancelled),
      rawPayload: params.event as unknown as Prisma.InputJsonValue,
      normalizedPayload: normalizedPayload as Prisma.InputJsonValue,
    });

    const diffPayload = await this.buildDiffPayload(
      latestVersion?.normalizedPayload as Record<string, any> | null,
      normalizedPayload,
      params.event,
      params.meetingId || null,
      latestVersion || null,
    );
    const diff = await this.outlookSyncRepository.createSyncDiff({
      bindingId: params.bindingId,
      sourceVersionId: sourceVersion.id,
      previousSourceVersionId: latestVersion?.id || null,
      detectedAt: fetchedAt,
      diffType: latestVersion ? 'UPDATED' : 'CREATED',
      changedFields: diffPayload.changedFields as Prisma.InputJsonValue,
      summaryJson: diffPayload.summary as Prisma.InputJsonValue,
      attendeesAdded: diffPayload.attendeesAdded as Prisma.InputJsonValue,
      attendeesRemoved: diffPayload.attendeesRemoved as Prisma.InputJsonValue,
      attendeesResponseChanged: diffPayload.attendeesResponseChanged as Prisma.InputJsonValue,
      graphAttendeesCountBefore: latestVersion?.attendeesCount || null,
      graphAttendeesCountAfter: sourceVersion.attendeesCount,
      internalMatchedCountBefore: diffPayload.internalMatchedBefore,
      internalMatchedCountAfter: diffPayload.internalMatchedAfter,
      meetingRequiredCountBefore: diffPayload.meetingRequiredBefore,
      meetingRequiredCountAfter: diffPayload.meetingRequiredAfter,
    });

    return {
      sourceVersionId: sourceVersion.id,
      diffId: diff.id,
      graphLastModifiedAt: graphLastModifiedAt?.toISOString() || null,
      fetchedAt: fetchedAt.toISOString(),
      hasContentChange: true,
      changedFields: diffPayload.changedFields,
      changeSummary: diffPayload.summary,
      attendeesAdded: diffPayload.attendeesAdded,
      attendeesRemoved: diffPayload.attendeesRemoved,
      attendeesResponseChanged: diffPayload.attendeesResponseChanged,
    };
  }

  private normalizeEventPayload(event: GraphEventInfo) {
    const attendees = (event.attendees || [])
      .map((item) => {
        const email = item.emailAddress?.address?.trim().toLowerCase();
        if (!email) {
          return null;
        }
        return {
          email,
          displayName: item.emailAddress?.name || null,
          type: item.type || 'required',
          response: item.status?.response || 'none',
        };
      })
      .filter((item): item is { email: string; displayName: string | null; type: string; response: string } => Boolean(item))
      .sort((a, b) => `${a.email}:${a.type}`.localeCompare(`${b.email}:${b.type}`));

    return {
      subject: event.subject || null,
      startTime: this.parseGraphDateTimeSafe(event.start, event)?.toISOString() || null,
      endTime: this.parseGraphDateTimeSafe(event.end, event)?.toISOString() || null,
      location: {
        displayName: event.location?.displayName || null,
      },
      isCancelled: Boolean(event.isCancelled),
      organizer: {
        email: event.organizer?.emailAddress?.address?.trim().toLowerCase() || null,
        name: event.organizer?.emailAddress?.name || null,
      },
      attendees,
    };
  }

  private async buildDiffPayload(
    previous: Record<string, any> | null,
    current: Record<string, any>,
    event: GraphEventInfo,
    meetingId: string | null,
    latestVersion: any,
  ) {
    const changedFields: string[] = [];
    for (const field of ['subject', 'startTime', 'endTime', 'isCancelled']) {
      if ((previous?.[field] ?? null) !== (current[field] ?? null)) {
        changedFields.push(field);
      }
    }
    if ((previous?.location?.displayName ?? null) !== (current.location?.displayName ?? null)) {
      changedFields.push('location');
    }
    if ((previous?.organizer?.email ?? null) !== (current.organizer?.email ?? null)) {
      changedFields.push('organizer');
    }

    const previousAttendees = new Map<string, any>((previous?.attendees || []).map((item: any) => [`${item.email}:${item.type}`, item]));
    const currentAttendees = new Map<string, any>((current.attendees || []).map((item: any) => [`${item.email}:${item.type}`, item]));
    const attendeesAdded = Array.from(currentAttendees.entries())
      .filter(([key]) => !previousAttendees.has(key))
      .map(([, value]) => value);
    const attendeesRemoved = Array.from(previousAttendees.entries())
      .filter(([key]) => !currentAttendees.has(key))
      .map(([, value]) => value);
    const attendeesResponseChanged = Array.from(currentAttendees.entries())
      .filter(([key, value]) => previousAttendees.has(key) && previousAttendees.get(key)?.response !== value.response)
      .map(([key, value]) => ({
        email: value.email,
        type: value.type,
        before: previousAttendees.get(key)?.response || null,
        after: value.response || null,
      }));
    if (attendeesAdded.length || attendeesRemoved.length || attendeesResponseChanged.length) {
      changedFields.push('attendees');
    }

    const internalMatchedAfter = await this.countInternalMatchedAttendees(event);
    const meetingRequiredAfter = await this.countMeetingRequiredAttendees(meetingId);
    const internalMatchedBefore = latestVersion
      ? await this.countInternalMatchedFromNormalizedPayload(previous)
      : null;
    const meetingRequiredBefore = latestVersion?.attendeesCount != null
      ? (latestVersion as any).attendeesCount === 0 ? 0 : await this.countMeetingRequiredAttendees(meetingId)
      : null;

    return {
      changedFields,
      attendeesAdded,
      attendeesRemoved,
      attendeesResponseChanged,
      internalMatchedBefore,
      internalMatchedAfter,
      meetingRequiredBefore,
      meetingRequiredAfter,
      summary: {
        before: previous,
        after: current,
        counts: {
          graphAttendees: {
            before: previous?.attendees?.length ?? null,
            after: current.attendees.length,
          },
          internalMatched: {
            before: internalMatchedBefore,
            after: internalMatchedAfter,
          },
          meetingRequired: {
            before: meetingRequiredBefore,
            after: meetingRequiredAfter,
          },
        },
        deltas: {
          attendeesAddedCount: attendeesAdded.length,
          attendeesRemovedCount: attendeesRemoved.length,
          attendeesResponseChangedCount: attendeesResponseChanged.length,
        },
      },
    };
  }

  private async countInternalMatchedAttendees(event: GraphEventInfo) {
    const emails = Array.from(new Set(
      (event.attendees || [])
        .map((item) => item.emailAddress?.address?.trim().toLowerCase())
        .filter((value): value is string => Boolean(value)),
    ));
    if (!emails.length) {
      return 0;
    }
    return this.prisma.user.count({
      where: {
        email: { in: emails },
        deletedAt: null,
      },
    });
  }

  private async countInternalMatchedFromNormalizedPayload(payload: Record<string, any> | null) {
    const emails: string[] = Array.from(new Set(
      (payload?.attendees || [])
        .map((item: any) => item.email)
        .filter((value: unknown): value is string => typeof value === 'string' && value.length > 0),
    ));
    if (!emails.length) {
      return 0;
    }
    return this.prisma.user.count({
      where: {
        email: { in: emails },
        deletedAt: null,
      },
    });
  }

  private async countMeetingRequiredAttendees(meetingId: string | null) {
    if (!meetingId) {
      return 0;
    }
    return this.prisma.meetingRequiredAttendee.count({
      where: { meetingId },
    });
  }

  private async logBindingEvent(
    bindingId: string,
    mailboxId: string,
    params: {
      eventType: string;
      resultStatus?: 'SUCCESS' | 'ERROR' | 'INFO';
      message?: string;
      payload?: Record<string, unknown> | null;
    },
  ) {
    try {
      await this.outlookSyncRepository.createBindingSyncEventLog({
        bindingId,
        mailboxId,
        eventType: params.eventType,
        resultStatus: params.resultStatus || 'SUCCESS',
        message: params.message,
        payload: (params.payload || undefined) as Prisma.InputJsonValue,
      });
    } catch (error) {
      logger.warn(`Failed to write Outlook binding sync event log: ${String(error)}`);
    }
  }

  private async logSyncErrorByGraphEventId(
    graphEventId: string,
    mailboxId: string,
    stage: string,
    error: unknown,
  ) {
    const parsed = this.extractGraphError(error);
    const bindings = await this.outlookSyncRepository.findBindingsByGraphEventId(graphEventId);
    for (const binding of bindings) {
      if (binding.manageStatus !== OutlookManageStatus.MANAGED) {
        continue;
      }
      const effectiveMailboxId = binding.primaryMailboxId;
      if (effectiveMailboxId !== mailboxId) {
        continue;
      }
      await this.logBindingEvent(binding.id, binding.primaryMailboxId, {
        eventType: 'SYNC_ERROR',
        resultStatus: 'ERROR',
        message: `${stage}: ${parsed.errorMessage}`,
        payload: {
          stage,
          graphEventId,
          mailboxId,
          errorCode: parsed.errorCode,
          statusCode: parsed.statusCode,
          errorMessage: parsed.errorMessage,
        },
      });
    }
  }

  private extractGraphError(error: unknown): {
    statusCode: number | null;
    errorCode: string | null;
    errorMessage: string;
  } {
    const raw = error as any;
    const statusCode = typeof raw?.statusCode === 'number' ? raw.statusCode : null;
    const errorCode = raw?.body?.error?.code || raw?.code || null;
    const errorMessage = raw?.body?.error?.message
      || raw?.message
      || String(error);
    return {
      statusCode,
      errorCode,
      errorMessage,
    };
  }

  private wrapGraphError(error: unknown, message: string): MeetingAttendanceError {
    if (error instanceof MeetingAttendanceError) {
      return error;
    }

    const graphCode = (error as any)?.statusCode;
    const graphMessage = (error as any)?.body?.error?.message
      || (error as any)?.message
      || '';
    if (graphCode === 404) {
      return new MeetingAttendanceError(
        400,
        `Graph mailbox or calendar not found${graphMessage ? ` (${graphMessage})` : ''}`,
      );
    }
    if (graphCode === 401 || graphCode === 403) {
      return new MeetingAttendanceError(
        502,
        `${message}: Graph authorization failed${graphMessage ? ` (${graphMessage})` : ''}`,
      );
    }
    if (graphCode === 400) {
      return new MeetingAttendanceError(
        400,
        `${message}: Graph request invalid${graphMessage ? ` (${graphMessage})` : ''}`,
      );
    }
    return new MeetingAttendanceError(
      502,
      `${message}: Graph API call failed${graphMessage ? ` (${graphMessage})` : ''}`,
    );
  }
}
