import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { PrismaService } from '@/core/database/prisma/prisma.service';
import { createTestApp } from '../../helpers/app.helper';
import { cleanupDatabase } from '../../helpers/cleanup.helper';
import {
  assignRoleToUser,
  createAdminUser,
  createTestUser,
} from '../../helpers/factories';
import { OutlookSyncService } from '@/modules/meeting-attendance/services/outlook-sync.service';

/**
 * Meeting-attendance · Outlook Sync Controller L1 集成测试
 *
 * 覆盖 18 个 endpoint 的 HTTP 行为 + 鉴权 + 核心业务规则：
 *   GET    /meeting-attendance/integrations/outlook/candidates
 *   GET    /meeting-attendance/integrations/outlook/candidates/:seriesMasterId/children
 *   POST   /meeting-attendance/integrations/outlook/bindings
 *   POST   /meeting-attendance/integrations/outlook/bindings/:id/takeover
 *   POST   /meeting-attendance/integrations/outlook/bindings/:id/unmanage
 *   POST   /meeting-attendance/integrations/outlook/bindings/:id/resume-sync
 *   GET    /meeting-attendance/integrations/outlook/bindings/:id/exclusions
 *   POST   /meeting-attendance/integrations/outlook/bindings/:id/exclusions
 *   POST   /meeting-attendance/integrations/outlook/exclusions/:id/remove
 *   GET    /meeting-attendance/integrations/outlook/bindings
 *   GET    /meeting-attendance/integrations/outlook/bindings/series/:seriesMasterId/children
 *   GET    /meeting-attendance/integrations/outlook/bindings/all           (Administrator only)
 *   GET    /meeting-attendance/integrations/outlook/bindings/:id
 *   GET    /meeting-attendance/integrations/outlook/bindings/:id/history
 *   GET    /meeting-attendance/integrations/outlook/bindings/:id/history/export.csv
 *   POST   /meeting-attendance/integrations/outlook/sync/reconcile
 *   GET    /meeting-attendance/integrations/outlook/settings
 *   PATCH  /meeting-attendance/integrations/outlook/settings
 *
 * Graph API 调用策略：
 *   - 调 Graph 的方法（manageBinding, takeoverBinding, excludeSeriesOccurrence, listCandidates, listCandidateSeriesChildren）
 *     通过 jest.spyOn 拦截，不实际调 MS Graph。
 *   - 不调 Graph 的 endpoint 直接用真实 DB fixture 验证。
 *
 * 关联工单 #341 · Batch outlook-sync
 */
describe('Meeting-attendance · Outlook Sync API', () => {
  let app: INestApplication;
  let prisma: PrismaService;

  beforeAll(async () => {
    process.env.NODE_ENV = 'test';
    app = await createTestApp();
    prisma = app.get<PrismaService>(PrismaService);
  });

  afterEach(async () => {
    jest.restoreAllMocks();
    await cleanupDatabase(prisma);
  });

  afterAll(async () => {
    await app.close();
  });

  // ============================================================
  // helpers
  // ============================================================

  function suffix() {
    return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
  }

  async function login(username: string, password: string): Promise<string> {
    const resp = await request(app.getHttpServer())
      .post('/api/v1/auth/login')
      .send({ username, password })
      .expect(200);
    return resp.body.data.accessToken as string;
  }

  async function ensureRole(code: 'Administrator' | 'Employee' | 'MeetingManager') {
    return prisma.role.upsert({
      where: { code },
      create: { code, name: code, enabled: true, isBuiltIn: true },
      update: { enabled: true },
    });
  }

  async function setupAdmin() {
    const s = suffix();
    const adminUser = await createAdminUser({
      username: `t_ols_adm_${s}`,
      email: `t_ols_adm_${s}@example.com`,
      password: 'Admin@123',
      displayName: `Outlook Admin ${s}`,
    });
    const adminToken = await login(adminUser.username, 'Admin@123');
    return { adminUser, adminToken };
  }

  async function setupEmployee() {
    const s = suffix();
    const employee = await createTestUser({
      username: `t_ols_emp_${s}`,
      email: `t_ols_emp_${s}@example.com`,
      password: 'Emp@123',
      displayName: `Outlook Emp ${s}`,
    });
    const role = await ensureRole('Employee');
    await assignRoleToUser(employee.id, role.id);
    const empToken = await login(employee.username, 'Emp@123');
    return { employee, empToken };
  }

  async function setupManager() {
    const s = suffix();
    const manager = await createTestUser({
      username: `t_ols_mgr_${s}`,
      email: `t_ols_mgr_${s}@example.com`,
      password: 'Mgr@123',
      displayName: `Outlook Mgr ${s}`,
    });
    const role = await ensureRole('MeetingManager');
    await assignRoleToUser(manager.id, role.id);
    const mgrToken = await login(manager.username, 'Mgr@123');
    return { manager, mgrToken };
  }

  /** 创建一个 OutlookSyncMailbox fixture */
  async function createMailbox(opts?: { email?: string; isEnabled?: boolean }) {
    const s = suffix();
    return (prisma as any).outlookSyncMailbox.create({
      data: {
        mailboxEmail: opts?.email ?? `t_mailbox_${s}@corp.example.com`,
        mailboxType: 'SHARED',
        isEnabled: opts?.isEnabled ?? true,
        isPrimaryDefault: false,
      },
    });
  }

  /** 创建一个 OutlookMeetingBinding fixture（MANAGED 状态） */
  async function createBinding(opts: {
    mailboxId: string;
    ownerUserId: string;
    ownerEmail: string;
    manageStatus?: string;
    syncMode?: string;
    graphEventType?: string;
  }) {
    const s = suffix();
    return (prisma as any).outlookMeetingBinding.create({
      data: {
        graphEventId: `t_evt_${s}`,
        iCalUId: `t_ical_${s}`,
        // service 标准化后存 'single'（非 MS Graph 原始值 'singleInstance'）
        // repository listManagedBindings 过滤: in: ['single', 'seriesMaster']
        graphEventType: opts.graphEventType ?? 'single',
        manageStatus: opts.manageStatus ?? 'MANAGED',
        bootstrapStatus: 'SUCCEEDED',
        bootstrapUpdatedAt: new Date(),
        ownerUserId: opts.ownerUserId,
        ownerEmail: opts.ownerEmail,
        primaryMailboxId: opts.mailboxId,
        syncFrom: new Date(),
        lastSyncedAt: new Date(),
        syncMode: opts.syncMode ?? 'AUTO',
      },
    });
  }

  /** 创建一个 series 类型的 binding */
  async function createSeriesBinding(opts: {
    mailboxId: string;
    ownerUserId: string;
    ownerEmail: string;
  }) {
    const s = suffix();
    return (prisma as any).outlookMeetingBinding.create({
      data: {
        graphEventId: `t_series_evt_${s}`,
        iCalUId: `t_series_ical_${s}`,
        graphSeriesMasterId: `t_series_evt_${s}`,
        graphEventType: 'seriesMaster',
        manageStatus: 'MANAGED',
        bootstrapStatus: 'SUCCEEDED',
        bootstrapUpdatedAt: new Date(),
        ownerUserId: opts.ownerUserId,
        ownerEmail: opts.ownerEmail,
        primaryMailboxId: opts.mailboxId,
        syncFrom: new Date(),
        lastSyncedAt: new Date(),
        syncMode: 'AUTO',
      },
    });
  }

  /** 创建 occurrence exclusion fixture */
  async function createExclusion(bindingId: string, opts?: { createdByEmail?: string }) {
    const s = suffix();
    return (prisma as any).outlookSeriesOccurrenceExclusion.create({
      data: {
        bindingId,
        occurrenceGraphEventId: `t_occ_${s}`,
        iCalUId: `t_occ_ical_${s}`,
        reason: 'test exclusion',
        createdByEmail: opts?.createdByEmail ?? 't_exclusion@example.com',
      },
    });
  }

  /** spy OutlookSyncService 上的私有 Graph 调用方法 */
  function spyGraphFetchEvent(resolvedEvent: any) {
    const svc = app.get(OutlookSyncService);
    return jest.spyOn(svc as any, 'fetchEventById').mockResolvedValue(resolvedEvent);
  }

  function spyResolveManageRootEvent(resolvedEvent: any) {
    const svc = app.get(OutlookSyncService);
    return jest.spyOn(svc as any, 'resolveManageRootEvent').mockResolvedValue(resolvedEvent);
  }

  function spyFetchCandidates(items: any[]) {
    const svc = app.get(OutlookSyncService);
    // listCandidates 内部调 loadCandidateSnapshots（从 DB），我们 spy 顶层 listCandidates
    return jest.spyOn(svc, 'listCandidates').mockResolvedValue({
      mailbox: { id: 'mock-mailbox', mailboxEmail: 'mock@example.com', mailboxType: 'SHARED' },
      items,
      snapshotInitializing: false,
      sourceLagSeconds: 0,
      pagination: { page: 1, pageSize: 20, total: items.length, totalPages: 1 },
    } as any);
  }

  function spyFetchCandidateSeriesChildren(items: any[]) {
    const svc = app.get(OutlookSyncService);
    return jest.spyOn(svc, 'listCandidateSeriesChildren').mockResolvedValue({
      mailbox: { id: 'mock-mailbox', mailboxEmail: 'mock@example.com', mailboxType: 'SHARED' },
      seriesMasterId: 'mock-series-id',
      items,
    } as any);
  }

  const BASE = '/api/v1/meeting-attendance/integrations/outlook';

  // ============================================================
  // GET /candidates
  // ============================================================

  describe('GET /candidates', () => {
    it('[1] admin 调用 → 200 + items 列表（spy listCandidates）', async () => {
      const { adminToken } = await setupAdmin();
      spyFetchCandidates([
        { graphEventId: 'evt-1', subject: 'Mock Meeting', eventType: 'singleInstance' },
      ]);

      const resp = await request(app.getHttpServer())
        .get(`${BASE}/candidates`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      expect(resp.body.items).toHaveLength(1);
      expect(resp.body.pagination).toBeDefined();
    });

    it('[2] MeetingManager 也可访问 → 200', async () => {
      const { mgrToken } = await setupManager();
      spyFetchCandidates([]);

      const resp = await request(app.getHttpServer())
        .get(`${BASE}/candidates`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .expect(200);

      expect(resp.body.items).toBeDefined();
    });

    it('[3] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .get(`${BASE}/candidates`)
        .expect(401);
    });

    it('[4] 普通员工 → 403', async () => {
      const { empToken } = await setupEmployee();
      await request(app.getHttpServer())
        .get(`${BASE}/candidates`)
        .set('Authorization', `Bearer ${empToken}`)
        .expect(403);
    });

    it('[5] mailboxId 不存在 → 400 Source mailbox unavailable', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .get(`${BASE}/candidates?mailboxId=non-existent-mailbox-id`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(400);

      expect(resp.body.error).toMatch(/mailbox unavailable/i);
    });
  });

  // ============================================================
  // GET /candidates/:seriesMasterId/children
  // ============================================================

  describe('GET /candidates/:seriesMasterId/children', () => {
    it('[6] admin 调用 → 200 + items（spy listCandidateSeriesChildren）', async () => {
      const { adminToken } = await setupAdmin();
      spyFetchCandidateSeriesChildren([
        { graphEventId: 'occ-1', subject: 'Occurrence 1', eventType: 'occurrence' },
      ]);

      const resp = await request(app.getHttpServer())
        .get(`${BASE}/candidates/mock-series-master/children`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      expect(resp.body.items).toHaveLength(1);
    });

    it('[7] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .get(`${BASE}/candidates/some-series/children`)
        .expect(401);
    });

    it('[8] 普通员工 → 403', async () => {
      const { empToken } = await setupEmployee();
      await request(app.getHttpServer())
        .get(`${BASE}/candidates/some-series/children`)
        .set('Authorization', `Bearer ${empToken}`)
        .expect(403);
    });
  });

  // ============================================================
  // POST /bindings  (manageBinding — 调 Graph，需 spy)
  // ============================================================

  describe('POST /bindings', () => {
    it('[9] MANAGE 成功：spy fetchEventById + resolveManageRootEvent → 201 + binding', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });

      const s = suffix();
      const mockEvent = {
        id: `t_evt_${s}`,
        iCalUId: `t_ical_${s}`,
        subject: 'Mock Meeting',
        type: 'singleInstance',
        isCancelled: false,
        seriesMasterId: null,
        attendees: [],
        organizer: { emailAddress: { address: adminUser.email, name: adminUser.username } },
        start: { dateTime: new Date().toISOString(), timeZone: 'UTC' },
        end: { dateTime: new Date(Date.now() + 3600000).toISOString(), timeZone: 'UTC' },
      };

      spyGraphFetchEvent(mockEvent);
      spyResolveManageRootEvent(mockEvent);

      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({
          mailboxId: mailbox.id,
          graphEventId: mockEvent.id,
          iCalUId: mockEvent.iCalUId,
          action: 'MANAGE',
        })
        .expect(201);

      expect(resp.body.binding).toBeDefined();
      expect(resp.body.binding.manageStatus).toBe('MANAGED');
    });

    it('[10] MANAGE：mailboxId 不存在 → 400 Source mailbox unavailable', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({
          mailboxId: 'non-existent-mailbox',
          graphEventId: 'some-graph-event',
          iCalUId: 'some-ical',
          action: 'MANAGE',
        })
        .expect(400);

      expect(resp.body.error).toMatch(/mailbox unavailable/i);
    });

    it('[11] MANAGE：Graph 返回 null（event 不存在）→ 404', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      spyGraphFetchEvent(null);

      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({
          mailboxId: mailbox.id,
          graphEventId: 'non-existent-event',
          iCalUId: 'some-ical',
          action: 'MANAGE',
        })
        .expect(404);

      expect(resp.body.error).toMatch(/not found/i);
    });

    it('[12] MANAGE：event 已被其他人绑定 → 409', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const { manager } = await setupManager();
      const mailbox = await createMailbox({ email: adminUser.email });

      const s = suffix();
      const mockEvent = {
        id: `t_evt_${s}`,
        iCalUId: `t_ical_${s}`,
        type: 'singleInstance',
        isCancelled: false,
        seriesMasterId: null,
        attendees: [],
        organizer: { emailAddress: { address: adminUser.email, name: 'admin' } },
        start: { dateTime: new Date().toISOString(), timeZone: 'UTC' },
        end: { dateTime: new Date(Date.now() + 3600000).toISOString(), timeZone: 'UTC' },
      };

      // 先由 manager 创建一个 binding 占位（DB 直接插）
      await (prisma as any).outlookMeetingBinding.create({
        data: {
          graphEventId: mockEvent.id,
          iCalUId: mockEvent.iCalUId,
          graphEventType: 'singleInstance',
          manageStatus: 'MANAGED',
          bootstrapStatus: 'SUCCEEDED',
          bootstrapUpdatedAt: new Date(),
          ownerUserId: manager.id,
          ownerEmail: manager.email,
          primaryMailboxId: mailbox.id,
          syncFrom: new Date(),
          lastSyncedAt: new Date(),
          syncMode: 'AUTO',
        },
      });

      spyGraphFetchEvent(mockEvent);
      spyResolveManageRootEvent(mockEvent);

      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({
          mailboxId: mailbox.id,
          graphEventId: mockEvent.id,
          iCalUId: mockEvent.iCalUId,
          action: 'MANAGE',
        })
        .expect(409);

      expect(resp.body.error).toMatch(/already bound/i);
    });

    it('[13] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .post(`${BASE}/bindings`)
        .send({ mailboxId: 'x', graphEventId: 'y', iCalUId: 'z', action: 'MANAGE' })
        .expect(401);
    });

    it('[14] 员工 → 403', async () => {
      const { empToken } = await setupEmployee();
      await request(app.getHttpServer())
        .post(`${BASE}/bindings`)
        .set('Authorization', `Bearer ${empToken}`)
        .send({ mailboxId: 'x', graphEventId: 'y', iCalUId: 'z', action: 'MANAGE' })
        .expect(403);
    });
  });

  // ============================================================
  // POST /bindings/:id/takeover  (调 Graph，需 spy)
  // ============================================================

  describe('POST /bindings/:id/takeover', () => {
    it('[15] takeover 成功 → 200 + binding（ownerUserId 变更）', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const { manager } = await setupManager();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createBinding({
        mailboxId: mailbox.id,
        ownerUserId: manager.id,
        ownerEmail: manager.email,
      });

      const mockEvent = {
        id: binding.graphEventId,
        iCalUId: binding.iCalUId,
        type: 'singleInstance',
        isCancelled: false,
        seriesMasterId: null,
        attendees: [],
        organizer: { emailAddress: { address: adminUser.email, name: 'admin' } },
        start: { dateTime: new Date().toISOString(), timeZone: 'UTC' },
        end: { dateTime: new Date(Date.now() + 3600000).toISOString(), timeZone: 'UTC' },
      };

      spyGraphFetchEvent(mockEvent);
      spyResolveManageRootEvent(mockEvent);

      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings/${binding.id}/takeover`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ mailboxId: mailbox.id })
        .expect(200);

      expect(resp.body.ownerUserId).toBe(adminUser.id);
    });

    it('[16] binding 不存在 → 404', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings/non-existent-binding-id/takeover`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({})
        .expect(404);

      expect(resp.body.error).toMatch(/not found/i);
    });

    it('[17] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .post(`${BASE}/bindings/some-id/takeover`)
        .send({})
        .expect(401);
    });
  });

  // ============================================================
  // POST /bindings/:id/unmanage
  // ============================================================

  describe('POST /bindings/:id/unmanage', () => {
    it('[18] 自己的 binding → 200 + manageStatus = PENDING_SELECTION', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
      });

      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings/${binding.id}/unmanage`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      expect(resp.body.manageStatus).toBe('PENDING_SELECTION');
    });

    it('[19] binding 不存在 → 404', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings/non-existent-binding/unmanage`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(404);

      expect(resp.body.error).toMatch(/not found/i);
    });

    it('[20] 其他人的 binding → 409', async () => {
      const { manager } = await setupManager();
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createBinding({
        mailboxId: mailbox.id,
        ownerUserId: manager.id,
        ownerEmail: manager.email,
      });

      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings/${binding.id}/unmanage`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(409);

      expect(resp.body.error).toMatch(/already bound/i);
    });

    it('[21] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .post(`${BASE}/bindings/some-id/unmanage`)
        .expect(401);
    });
  });

  // ============================================================
  // POST /bindings/:id/resume-sync
  // ============================================================

  describe('POST /bindings/:id/resume-sync', () => {
    it('[22] LOCKED_BY_LOCAL_EDIT → 200 + syncMode=AUTO', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
        syncMode: 'LOCKED_BY_LOCAL_EDIT',
      });

      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings/${binding.id}/resume-sync`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      expect(resp.body.syncMode).toBe('AUTO');
    });

    it('[23] binding 未锁定（AUTO 模式）→ 400 not locally locked', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
        syncMode: 'AUTO',
      });

      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings/${binding.id}/resume-sync`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(400);

      expect(resp.body.error).toMatch(/not locally locked/i);
    });

    it('[24] binding 不存在 → 404', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings/non-existent-id/resume-sync`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(404);

      expect(resp.body.error).toMatch(/not found/i);
    });

    it('[25] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .post(`${BASE}/bindings/some-id/resume-sync`)
        .expect(401);
    });
  });

  // ============================================================
  // GET /bindings/:id/exclusions
  // ============================================================

  describe('GET /bindings/:id/exclusions', () => {
    it('[26] 有 exclusions → 200 + items 列表', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createSeriesBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
      });
      await createExclusion(binding.id);
      await createExclusion(binding.id);

      const resp = await request(app.getHttpServer())
        .get(`${BASE}/bindings/${binding.id}/exclusions`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      expect(resp.body.items).toHaveLength(2);
      expect(resp.body.pagination.total).toBe(2);
    });

    it('[27] 无 exclusions → 200 + empty items', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createSeriesBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
      });

      const resp = await request(app.getHttpServer())
        .get(`${BASE}/bindings/${binding.id}/exclusions`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      expect(resp.body.items).toHaveLength(0);
    });

    it('[28] binding 不存在 → 404', async () => {
      const { adminToken } = await setupAdmin();
      await request(app.getHttpServer())
        .get(`${BASE}/bindings/non-existent-id/exclusions`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(404);
    });

    it('[29] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .get(`${BASE}/bindings/some-id/exclusions`)
        .expect(401);
    });
  });

  // ============================================================
  // POST /bindings/:id/exclusions  (调 Graph fetchEventById，需 spy)
  // ============================================================

  describe('POST /bindings/:id/exclusions', () => {
    it('[30] 成功添加 exclusion → 201', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createSeriesBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
      });

      const s = suffix();
      const occurrenceGraphEventId = `t_occ_${s}`;
      const mockOccurrenceEvent = {
        id: occurrenceGraphEventId,
        iCalUId: `t_occ_ical_${s}`,
        type: 'occurrence',
        seriesMasterId: binding.graphEventId,
        isCancelled: false,
      };
      spyGraphFetchEvent(mockOccurrenceEvent);

      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings/${binding.id}/exclusions`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({
          occurrenceGraphEventId,
          reason: 'Skip this occurrence',
        })
        .expect(201);

      expect(resp.body).toBeDefined();

      const exclusion = await (prisma as any).outlookSeriesOccurrenceExclusion.findFirst({
        where: { bindingId: binding.id, occurrenceGraphEventId },
      });
      expect(exclusion).toBeTruthy();
    });

    it('[31] binding 是 singleInstance（非 seriesMaster）→ 400', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
        graphEventType: 'singleInstance',
      });

      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings/${binding.id}/exclusions`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ occurrenceGraphEventId: 'some-occ-id' })
        .expect(400);

      expect(resp.body.error).toMatch(/series binding/i);
    });

    it('[32] occurrence event 不属于该 series → 400', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createSeriesBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
      });

      const mockOccurrence = {
        id: 'some-occ-id',
        type: 'occurrence',
        seriesMasterId: 'different-series-master',  // 不属于此 binding
        isCancelled: false,
      };
      spyGraphFetchEvent(mockOccurrence);

      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings/${binding.id}/exclusions`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ occurrenceGraphEventId: 'some-occ-id' })
        .expect(400);

      expect(resp.body.error).toMatch(/not under the selected series/i);
    });

    it('[33] Graph 返回 null (occurrence 不存在) → 404', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createSeriesBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
      });
      spyGraphFetchEvent(null);

      const resp = await request(app.getHttpServer())
        .post(`${BASE}/bindings/${binding.id}/exclusions`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ occurrenceGraphEventId: 'non-existent-occ' })
        .expect(404);

      expect(resp.body.error).toMatch(/not found/i);
    });

    it('[34] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .post(`${BASE}/bindings/some-id/exclusions`)
        .send({ occurrenceGraphEventId: 'x' })
        .expect(401);
    });
  });

  // ============================================================
  // POST /exclusions/:id/remove
  // ============================================================

  describe('POST /exclusions/:id/remove', () => {
    it('[35] 成功移除 exclusion → 200 + removed=true', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createSeriesBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
      });
      const exclusion = await createExclusion(binding.id, { createdByEmail: adminUser.email });

      const resp = await request(app.getHttpServer())
        .post(`${BASE}/exclusions/${exclusion.id}/remove`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      expect(resp.body.removed).toBe(true);
      expect(resp.body.exclusionId).toBe(exclusion.id);

      const deleted = await (prisma as any).outlookSeriesOccurrenceExclusion.findUnique({
        where: { id: exclusion.id },
      });
      expect(deleted).toBeNull();
    });

    it('[36] exclusion 不存在 → 404', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .post(`${BASE}/exclusions/non-existent-exclusion-id/remove`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(404);

      expect(resp.body.error).toMatch(/not found/i);
    });

    it('[37] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .post(`${BASE}/exclusions/some-id/remove`)
        .expect(401);
    });

    it('[38] 普通员工 → 403', async () => {
      const { empToken } = await setupEmployee();
      await request(app.getHttpServer())
        .post(`${BASE}/exclusions/some-id/remove`)
        .set('Authorization', `Bearer ${empToken}`)
        .expect(403);
    });
  });

  // ============================================================
  // GET /bindings  (by mailboxId)
  // ============================================================

  describe('GET /bindings', () => {
    it('[39] 有 bindings → 200 + items', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      await createBinding({ mailboxId: mailbox.id, ownerUserId: adminUser.id, ownerEmail: adminUser.email });
      await createBinding({ mailboxId: mailbox.id, ownerUserId: adminUser.id, ownerEmail: adminUser.email });

      const resp = await request(app.getHttpServer())
        .get(`${BASE}/bindings?mailboxId=${mailbox.id}`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      expect(resp.body.items.length).toBeGreaterThanOrEqual(2);
      expect(resp.body.pagination.total).toBeGreaterThanOrEqual(2);
    });

    it('[40] mailboxId 不存在 → 404 Source mailbox not found', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .get(`${BASE}/bindings?mailboxId=non-existent-mailbox`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(404);

      expect(resp.body.error).toMatch(/not found/i);
    });

    it('[41] 无 mailboxId 参数（DTO 必填）→ 400', async () => {
      const { adminToken } = await setupAdmin();
      await request(app.getHttpServer())
        .get(`${BASE}/bindings`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(400);
    });

    it('[42] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .get(`${BASE}/bindings?mailboxId=x`)
        .expect(401);
    });

    it('[43] 员工 → 403', async () => {
      const { empToken } = await setupEmployee();
      await request(app.getHttpServer())
        .get(`${BASE}/bindings?mailboxId=x`)
        .set('Authorization', `Bearer ${empToken}`)
        .expect(403);
    });
  });

  // ============================================================
  // GET /bindings/series/:seriesMasterId/children
  // ============================================================

  describe('GET /bindings/series/:seriesMasterId/children', () => {
    it('[44] 有子 bindings → 200 + items', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const seriesBinding = await createSeriesBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
      });

      // spy listManagedSeriesChildren（纯 DB 查询，不调 Graph，但 spy 以控制返回值）
      const svc = app.get(OutlookSyncService);
      jest.spyOn(svc, 'listManagedSeriesChildren').mockResolvedValue({
        seriesMasterId: seriesBinding.graphEventId,
        items: [{ graphEventId: 'child-1' }],
        pagination: { page: 1, pageSize: 20, total: 1, totalPages: 1 },
      } as any);

      const resp = await request(app.getHttpServer())
        .get(`${BASE}/bindings/series/${seriesBinding.graphEventId}/children`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      expect(resp.body.items).toHaveLength(1);
    });

    it('[45] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .get(`${BASE}/bindings/series/some-series/children`)
        .expect(401);
    });
  });

  // ============================================================
  // GET /bindings/all  (Administrator only)
  // ============================================================

  describe('GET /bindings/all', () => {
    it('[46] Administrator → 200 + items (含跨 mailbox 数据)', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .get(`${BASE}/bindings/all`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      expect(resp.body.items).toBeDefined();
      expect(resp.body.pagination).toBeDefined();
    });

    it('[47] MeetingManager → 403 (需要 Administrator 角色)', async () => {
      const { mgrToken } = await setupManager();
      const resp = await request(app.getHttpServer())
        .get(`${BASE}/bindings/all`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .expect(403);

      expect(resp.body.error).toMatch(/administrator role required/i);
    });

    it('[48] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .get(`${BASE}/bindings/all`)
        .expect(401);
    });
  });

  // ============================================================
  // GET /bindings/:id
  // ============================================================

  describe('GET /bindings/:id', () => {
    it('[49] 存在的 binding → 200 + binding detail', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
      });

      const resp = await request(app.getHttpServer())
        .get(`${BASE}/bindings/${binding.id}`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      expect(resp.body.id).toBe(binding.id);
      expect(resp.body.manageStatus).toBe('MANAGED');
    });

    it('[50] 不存在的 binding → 404', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .get(`${BASE}/bindings/non-existent-id`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(404);

      expect(resp.body.error).toMatch(/not found/i);
    });

    it('[51] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .get(`${BASE}/bindings/some-id`)
        .expect(401);
    });
  });

  // ============================================================
  // GET /bindings/:id/history
  // ============================================================

  describe('GET /bindings/:id/history', () => {
    it('[52] 有 history → 200 + items', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
      });

      // 插入一条 sync event log
      await (prisma as any).outlookSyncEventLog.create({
        data: {
          bindingId: binding.id,
          mailboxId: mailbox.id,
          eventType: 'MANAGED',
          resultStatus: 'SUCCESS',
          message: 'Binding managed',
        },
      });

      const resp = await request(app.getHttpServer())
        .get(`${BASE}/bindings/${binding.id}/history`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      expect(resp.body.items.length).toBeGreaterThanOrEqual(1);
      expect(resp.body.pagination).toBeDefined();
    });

    it('[53] binding 不存在 → 404', async () => {
      const { adminToken } = await setupAdmin();
      await request(app.getHttpServer())
        .get(`${BASE}/bindings/non-existent/history`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(404);
    });

    it('[54] startDate > endDate → 400', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
      });

      const resp = await request(app.getHttpServer())
        .get(`${BASE}/bindings/${binding.id}/history?startDate=2024-12-31&endDate=2024-01-01`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(400);

      expect(resp.body.error).toMatch(/startDate must be less than/i);
    });

    it('[55] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .get(`${BASE}/bindings/some-id/history`)
        .expect(401);
    });
  });

  // ============================================================
  // GET /bindings/:id/history/export.csv
  // ============================================================

  describe('GET /bindings/:id/history/export.csv', () => {
    it('[56] 有 history → 200 + text/csv Content-Type', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });
      const binding = await createBinding({
        mailboxId: mailbox.id,
        ownerUserId: adminUser.id,
        ownerEmail: adminUser.email,
      });

      await (prisma as any).outlookSyncEventLog.create({
        data: {
          bindingId: binding.id,
          mailboxId: mailbox.id,
          eventType: 'MANAGED',
          resultStatus: 'SUCCESS',
          message: 'exported',
        },
      });

      const resp = await request(app.getHttpServer())
        .get(`${BASE}/bindings/${binding.id}/history/export.csv`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      expect(resp.headers['content-type']).toMatch(/text\/csv/i);
      expect(resp.headers['content-disposition']).toMatch(/attachment/i);
      // CSV 表头：escapeCsv 将每个字段用双引号包裹，实际格式为 "time","eventType",...
      expect(resp.text).toMatch(/["']?time["']?,["']?eventType["']?/i);
    });

    it('[57] binding 不存在 → 404', async () => {
      const { adminToken } = await setupAdmin();
      await request(app.getHttpServer())
        .get(`${BASE}/bindings/non-existent/history/export.csv`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(404);
    });

    it('[58] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .get(`${BASE}/bindings/some-id/history/export.csv`)
        .expect(401);
    });
  });

  // ============================================================
  // POST /sync/reconcile
  // ============================================================

  describe('POST /sync/reconcile', () => {
    it('[59] 不带 mailboxId → 200 + accepted=true (reconcile all)', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .post(`${BASE}/sync/reconcile`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({})
        .expect(200);

      expect(resp.body.accepted).toBe(true);
    });

    it('[60] 带存在的 mailboxId → 200 + accepted=true', async () => {
      const { adminUser, adminToken } = await setupAdmin();
      const mailbox = await createMailbox({ email: adminUser.email });

      const resp = await request(app.getHttpServer())
        .post(`${BASE}/sync/reconcile`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ mailboxId: mailbox.id })
        .expect(200);

      expect(resp.body.accepted).toBe(true);
      expect(resp.body.mailboxId).toBe(mailbox.id);
    });

    it('[61] mailboxId 不存在 → 404', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .post(`${BASE}/sync/reconcile`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ mailboxId: 'non-existent-mailbox' })
        .expect(404);

      expect(resp.body.error).toMatch(/not found/i);
    });

    it('[62] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .post(`${BASE}/sync/reconcile`)
        .send({})
        .expect(401);
    });

    it('[63] 员工 → 403', async () => {
      const { empToken } = await setupEmployee();
      await request(app.getHttpServer())
        .post(`${BASE}/sync/reconcile`)
        .set('Authorization', `Bearer ${empToken}`)
        .send({})
        .expect(403);
    });
  });

  // ============================================================
  // GET /settings
  // ============================================================

  describe('GET /settings', () => {
    it('[64] admin → 200 + settings 对象含标准字段', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .get(`${BASE}/settings`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      expect(resp.body).toMatchObject({
        reconcileCron: expect.any(String),
        deltaBatchSize: expect.any(Number),
        lookaheadDays: expect.any(Number),
        lookbackDays: expect.any(Number),
        renewBeforeMinutes: expect.any(Number),
      });
    });

    it('[65] MeetingManager → 200（有权限）', async () => {
      const { mgrToken } = await setupManager();
      await request(app.getHttpServer())
        .get(`${BASE}/settings`)
        .set('Authorization', `Bearer ${mgrToken}`)
        .expect(200);
    });

    it('[66] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .get(`${BASE}/settings`)
        .expect(401);
    });

    it('[67] 员工 → 403', async () => {
      const { empToken } = await setupEmployee();
      await request(app.getHttpServer())
        .get(`${BASE}/settings`)
        .set('Authorization', `Bearer ${empToken}`)
        .expect(403);
    });
  });

  // ============================================================
  // PATCH /settings
  // ============================================================

  describe('PATCH /settings', () => {
    it('[68] 更新 lookaheadDays → 200 + 值变更生效', async () => {
      const { adminToken } = await setupAdmin();

      const resp = await request(app.getHttpServer())
        .patch(`${BASE}/settings`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ lookaheadDays: 90 })
        .expect(200);

      expect(resp.body.lookaheadDays).toBe(90);
    });

    it('[69] 更新 reconcileCron → 200', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .patch(`${BASE}/settings`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ reconcileCron: '*/30 * * * *' })
        .expect(200);

      expect(resp.body.reconcileCron).toBe('*/30 * * * *');
    });

    it('[70] deltaBatchSize < 1 → 400 (DTO Min 验证)', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .patch(`${BASE}/settings`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ deltaBatchSize: 0 })
        .expect(400);

      expect(resp.body).toBeDefined();
    });

    it('[71] renewBeforeMinutes < 10 → 400 (DTO Min 验证)', async () => {
      const { adminToken } = await setupAdmin();
      const resp = await request(app.getHttpServer())
        .patch(`${BASE}/settings`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ renewBeforeMinutes: 5 })
        .expect(400);

      expect(resp.body).toBeDefined();
    });

    it('[72] 无 Authorization → 401', async () => {
      await request(app.getHttpServer())
        .patch(`${BASE}/settings`)
        .send({ lookaheadDays: 30 })
        .expect(401);
    });

    it('[73] 员工 → 403', async () => {
      const { empToken } = await setupEmployee();
      await request(app.getHttpServer())
        .patch(`${BASE}/settings`)
        .set('Authorization', `Bearer ${empToken}`)
        .send({ lookaheadDays: 30 })
        .expect(403);
    });
  });
});
