import {
  Injectable,
  ForbiddenException,
  BadRequestException,
  NotFoundException,
  UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from '@core/database/prisma/prisma.service';
import { CheckpointRepository } from '../repositories/checkpoint.repository';
import { AttendanceEventRepository } from '../repositories/attendance-event.repository';
import { DailySummaryRepository } from '../repositories/daily-summary.repository';
import { CheckinDto, GuestCheckinDto, GeoStatus } from '../dto/checkin.dto';
import { calculateDistance, getLocalDate } from '../utils/geo.util';
import { GeocodingService } from './geocoding.service';
import {
  validateQrToken,
  validateTicketShape,
  QrTokenScope,
} from '../utils/shared-checkin.util';
import { SiteAttendanceErrorCodes } from '../error-codes';
import { SiteEventType } from '@prisma/client';

@Injectable()
export class CheckinService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly checkpointRepo: CheckpointRepository,
    private readonly eventRepo: AttendanceEventRepository,
    private readonly summaryRepo: DailySummaryRepository,
    private readonly geocodingService: GeocodingService,
  ) {}

  /**
   * 按时序配对 CHECK_IN/CHECK_OUT 累加在岗秒数。
   * 末段若为 CHECK_IN 未配对，则计至 now（默认服务器当前时刻）。
   * 多次签到/签退场景：把每对 CHECK_IN→下一个 CHECK_OUT 视为一段。
   */
  private computeTodayDurationSeconds(
    events: Array<{ eventType: SiteEventType; timestamp: Date }>,
    now: Date = new Date(),
  ): number {
    let total = 0;
    let openCheckInAt: Date | null = null;
    for (const e of events) {
      if (e.eventType === SiteEventType.CHECK_IN) {
        if (openCheckInAt === null) openCheckInAt = e.timestamp;
      } else if (e.eventType === SiteEventType.CHECK_OUT) {
        if (openCheckInAt !== null) {
          total += Math.max(
            0,
            Math.floor((e.timestamp.getTime() - openCheckInAt.getTime()) / 1000),
          );
          openCheckInAt = null;
        }
      }
    }
    if (openCheckInAt !== null) {
      total += Math.max(
        0,
        Math.floor((now.getTime() - openCheckInAt.getTime()) / 1000),
      );
    }
    return total;
  }

  async getPublicCheckpoint(code: string, opts?: { qrToken?: string; ticket?: string }) {
    const cp = await this.checkpointRepo.findActiveByCode(code);
    if (!cp) {
      throw new NotFoundException({
        code: SiteAttendanceErrorCodes.CHECKPOINT_NOT_FOUND,
        message: 'Checkpoint not found or inactive',
      });
    }

    // v1.5 SIGNED 准入：
    // - 优先接受 ticket（仅验签 + 时效 + targetCode，不消费 nonce，nonce 由 /validate-ticket 消费）
    // - 其次接受 t= QR token
    // - 都没带 → QR_TOKEN_MISSING
    if (cp.accessMode === 'SIGNED') {
      if (opts?.ticket) {
        const shape = validateTicketShape(opts.ticket);
        if (shape.valid) {
          const payload = shape.payload!;
          // ticket 本地跳时 targetCode 必须匹配当前 checkpoint
          // 外部跳场景下 ticket 的 targetMode='external'，signin 页本身就是外部，payload.targetCode 为 null
          if (
            payload.targetMode === 'local' &&
            payload.targetCode !== code
          ) {
            throw new BadRequestException({
              code: SiteAttendanceErrorCodes.TICKET_TARGET_MISMATCH,
            });
          }
          // ticket 校验通过 → 跳过 QR token 检查
        } else {
          // ticket 无效 → 给 ticket 相关的错误码
          switch (shape.reason) {
            case 'expired':
              throw new UnauthorizedException({
                code: SiteAttendanceErrorCodes.TICKET_EXPIRED,
              });
            case 'malformed':
              throw new BadRequestException({
                code: SiteAttendanceErrorCodes.TICKET_MALFORMED,
              });
            case 'origin_not_allowed':
              throw new ForbiddenException({
                code: SiteAttendanceErrorCodes.TICKET_ORIGIN_NOT_ALLOWED,
              });
            default:
              throw new UnauthorizedException({
                code: SiteAttendanceErrorCodes.TICKET_INVALID,
              });
          }
        }
      } else if (!opts?.qrToken) {
        throw new UnauthorizedException({
          code: SiteAttendanceErrorCodes.QR_TOKEN_MISSING,
          message: 'Please access via the latest QR code',
        });
      } else {
        const scope: QrTokenScope = cp.sharedCheckinEnabled ? 'shared' : 'checkpoint';
        const v = validateQrToken({
          token: opts.qrToken,
          scope,
          checkpointCode: cp.code,
          rotationSeconds: cp.qrRotationSeconds,
          graceSeconds: cp.qrGraceSeconds,
        });
        if (!v.valid) {
          if (v.reason === 'expired') {
            throw new UnauthorizedException({
              code: SiteAttendanceErrorCodes.QR_TOKEN_EXPIRED,
            });
          }
          if (v.reason === 'malformed') {
            throw new BadRequestException({
              code: SiteAttendanceErrorCodes.QR_TOKEN_MALFORMED,
            });
          }
          throw new UnauthorizedException({
            code: SiteAttendanceErrorCodes.QR_TOKEN_INVALID,
          });
        }
      }
    }

    return {
      id: cp.id,
      name: cp.name,
      description: cp.description,
      address: cp.address,
      timezone: cp.timezone,
      geoPolicy: cp.geoPolicy,
      geoRadius: cp.geoPolicy !== 'SKIP' ? cp.geoRadius : undefined,
      allowUnauthenticatedCheckin: cp.allowUnauthenticatedCheckin,
      // v1.5 新增
      accessMode: cp.accessMode,
      sharedCheckinEnabled: cp.sharedCheckinEnabled,
      sharedCompanyId: cp.sharedCheckinEnabled ? cp.sharedCompanyId : undefined,
      sharedCompanyLabel: cp.sharedCheckinEnabled ? cp.sharedCompanyLabel : undefined,
    };
  }

  async searchUsers(code: string, query: string) {
    const cp = await this.checkpointRepo.findActiveByCode(code);
    if (!cp) {
      throw new NotFoundException('Checkpoint not found or inactive');
    }
    if (!cp.allowUnauthenticatedCheckin) {
      throw new ForbiddenException(
        'This checkpoint does not allow unauthenticated check-in',
      );
    }
    if (!query || query.trim().length < 3) {
      throw new BadRequestException('Search query must be at least 3 characters');
    }

    const trimmed = query.trim();
    const users = await this.prisma.user.findMany({
      where: {
        status: 'ACTIVE',
        OR: [
          { displayName: { contains: trimmed, mode: 'insensitive' } },
          { email: { contains: trimmed, mode: 'insensitive' } },
          { employeeId: { contains: trimmed, mode: 'insensitive' } },
        ],
      },
      select: {
        id: true,
        displayName: true,
        email: true,
        departmentMemberships: {
          where: { leftAt: null },
          take: 1,
          include: {
            department: { select: { name: true } },
          },
        },
      },
      take: 20,
    });

    return {
      users: users.map((u) => ({
        id: u.id,
        displayName: u.displayName,
        email: u.email,
        department: u.departmentMemberships[0]?.department?.name ?? null,
      })),
    };
  }

  async getTodayEvents(code: string, userId: string) {
    const cp = await this.checkpointRepo.findActiveByCode(code);
    if (!cp) {
      throw new NotFoundException('Checkpoint not found or inactive');
    }

    const localDate = getLocalDate(cp.timezone);
    const events = await this.eventRepo.findByCheckpointUserAndDate(
      cp.id,
      userId,
      localDate,
    );
    const summary = await this.summaryRepo.findByCheckpointUserAndDate(
      cp.id,
      userId,
      localDate,
    );

    return {
      events: events.map((e) => ({
        id: e.id,
        eventType: e.eventType,
        timestamp: e.timestamp.toISOString(),
        geoStatus: e.geoStatus,
        authMethod: e.authMethod,
        latitude: e.latitude,
        longitude: e.longitude,
        accuracy: e.accuracy,
        distanceToCheckpoint: e.distanceToCheckpoint,
      })),
      summary: {
        firstCheckIn: summary?.firstCheckInAt?.toISOString() ?? null,
        lastCheckOut: summary?.lastCheckOutAt?.toISOString() ?? null,
        totalEvents: (summary?.checkInCount ?? 0) + (summary?.checkOutCount ?? 0),
        todayDurationSeconds: this.computeTodayDurationSeconds(events),
      },
    };
  }

  async checkin(
    code: string,
    dto: CheckinDto,
    userId: string,
    ip?: string,
    userAgent?: string,
  ) {
    const cp = await this.checkpointRepo.findActiveByCode(code);
    if (!cp) {
      throw new NotFoundException('Checkpoint not found or inactive');
    }

    // Verify user is ACTIVE
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      select: { id: true, status: true },
    });
    if (!user || user.status !== 'ACTIVE') {
      throw new ForbiddenException('User is not active');
    }

    // Geo validation
    const geoResult = this.validateGeo(cp, dto);
    if (cp.geoPolicy === 'STRICT_BLOCK' && geoResult.blocked) {
      throw new ForbiddenException(
        `GEO_BLOCKED:${geoResult.geoStatus}`,
      );
    }

    // Workflow check: must alternate CHECK_IN → CHECK_OUT → CHECK_IN
    const localDate = getLocalDate(cp.timezone);
    await this.validateEventOrder(cp.id, userId, localDate, dto.eventType);

    // Duplicate check (30s)
    await this.checkDuplicateEvent(cp.id, userId, dto.eventType);

    // Create event
    const event = await this.eventRepo.create({
      checkpoint: { connect: { id: cp.id } },
      user: { connect: { id: userId } },
      eventType: dto.eventType as any,
      localDate,
      authMethod: 'AUTHENTICATED' as any,
      latitude: dto.latitude,
      longitude: dto.longitude,
      accuracy: dto.accuracy,
      geoStatus: geoResult.geoStatus as any,
      distanceToCheckpoint: geoResult.distance,
      deviceId: dto.deviceId,
      userAgent,
      ipAddress: ip,
    });

    // Update daily summary
    const hasGeoAnomaly =
      geoResult.geoStatus !== 'VALID' && geoResult.geoStatus !== 'SKIPPED';
    await this.summaryRepo.upsertSummary(cp.id, userId, localDate, {
      eventType: dto.eventType,
      timestamp: event.timestamp,
      eventId: event.id,
      hasGeoAnomaly,
    });

    const [summary, todayEvents] = await Promise.all([
      this.summaryRepo.findByCheckpointUserAndDate(cp.id, userId, localDate),
      this.eventRepo.findByCheckpointUserAndDate(cp.id, userId, localDate),
    ]);

    return {
      event: {
        id: event.id,
        eventType: event.eventType,
        timestamp: event.timestamp.toISOString(),
        geoStatus: event.geoStatus,
        distanceToCheckpoint: event.distanceToCheckpoint,
      },
      todaySummary: {
        firstCheckIn: summary?.firstCheckInAt?.toISOString() ?? null,
        lastCheckOut: summary?.lastCheckOutAt?.toISOString() ?? null,
        totalEvents:
          (summary?.checkInCount ?? 0) + (summary?.checkOutCount ?? 0),
        todayDurationSeconds: this.computeTodayDurationSeconds(todayEvents),
      },
    };
  }

  async guestCheckin(
    code: string,
    dto: GuestCheckinDto,
    ip?: string,
    userAgent?: string,
  ) {
    const cp = await this.checkpointRepo.findActiveByCode(code);
    if (!cp) {
      throw new NotFoundException('Checkpoint not found or inactive');
    }
    if (!cp.allowUnauthenticatedCheckin) {
      throw new ForbiddenException(
        'This checkpoint does not allow unauthenticated check-in',
      );
    }

    // Verify user exists and is ACTIVE
    const user = await this.prisma.user.findUnique({
      where: { id: dto.userId },
      select: { id: true, status: true },
    });
    if (!user) {
      throw new NotFoundException('User not found');
    }
    if (user.status !== 'ACTIVE') {
      throw new ForbiddenException('User is not active');
    }

    // Geo validation
    const geoResult = this.validateGeo(cp, dto);
    if (cp.geoPolicy === 'STRICT_BLOCK' && geoResult.blocked) {
      throw new ForbiddenException(
        `GEO_BLOCKED:${geoResult.geoStatus}`,
      );
    }

    // Workflow check
    const localDate = getLocalDate(cp.timezone);
    await this.validateEventOrder(cp.id, dto.userId, localDate, dto.eventType);

    // Duplicate check (30s)
    await this.checkDuplicateEvent(cp.id, dto.userId, dto.eventType);

    const event = await this.eventRepo.create({
      checkpoint: { connect: { id: cp.id } },
      user: { connect: { id: dto.userId } },
      eventType: dto.eventType as any,
      localDate,
      authMethod: 'UNAUTHENTICATED' as any,
      latitude: dto.latitude,
      longitude: dto.longitude,
      accuracy: dto.accuracy,
      geoStatus: geoResult.geoStatus as any,
      distanceToCheckpoint: geoResult.distance,
      deviceId: dto.deviceId,
      userAgent,
      ipAddress: ip,
    });

    const hasGeoAnomaly =
      geoResult.geoStatus !== 'VALID' && geoResult.geoStatus !== 'SKIPPED';
    await this.summaryRepo.upsertSummary(cp.id, dto.userId, localDate, {
      eventType: dto.eventType,
      timestamp: event.timestamp,
      eventId: event.id,
      hasGeoAnomaly,
    });

    const [summary, todayEvents] = await Promise.all([
      this.summaryRepo.findByCheckpointUserAndDate(cp.id, dto.userId, localDate),
      this.eventRepo.findByCheckpointUserAndDate(cp.id, dto.userId, localDate),
    ]);

    return {
      event: {
        id: event.id,
        eventType: event.eventType,
        timestamp: event.timestamp.toISOString(),
        geoStatus: event.geoStatus,
        distanceToCheckpoint: event.distanceToCheckpoint,
      },
      todaySummary: {
        firstCheckIn: summary?.firstCheckInAt?.toISOString() ?? null,
        lastCheckOut: summary?.lastCheckOutAt?.toISOString() ?? null,
        totalEvents:
          (summary?.checkInCount ?? 0) + (summary?.checkOutCount ?? 0),
        todayDurationSeconds: this.computeTodayDurationSeconds(todayEvents),
      },
    };
  }

  async getCheckpointEvents(
    checkpointId: string,
    date?: string,
    page: number = 1,
    pageSize: number = 50,
    userName?: string,
    eventType?: string,
    lang?: string,
  ) {
    const cp = await this.prisma.siteCheckpoint.findUnique({
      where: { id: checkpointId },
    });
    if (!cp) {
      throw new NotFoundException('Checkpoint not found');
    }

    const localDate = date ?? getLocalDate(cp.timezone);
    const where: any = { checkpointId, localDate };
    if (eventType && (eventType === 'CHECK_IN' || eventType === 'CHECK_OUT')) {
      where.eventType = eventType;
    }
    if (userName) {
      where.user = { displayName: { contains: userName, mode: 'insensitive' } };
    }

    const [events, total] = await Promise.all([
      this.prisma.siteAttendanceEvent.findMany({
        where,
        orderBy: { timestamp: 'desc' },
        skip: (page - 1) * pageSize,
        take: pageSize,
        include: { user: { select: { id: true, displayName: true, email: true } } },
      }),
      this.prisma.siteAttendanceEvent.count({ where }),
    ]);

    // Batch reverse geocode — dedupe by coordinate
    const coordMap = new Map<string, { lat: number; lon: number }>();
    for (const e of events) {
      if (e.latitude != null && e.longitude != null) {
        const key = `${e.latitude.toFixed(5)},${e.longitude.toFixed(5)}`;
        if (!coordMap.has(key)) coordMap.set(key, { lat: e.latitude, lon: e.longitude });
      }
    }
    const addressMap = new Map<string, string>();
    const geoLang = lang === 'en' ? 'en,zh-CN,zh' : 'zh-CN,zh,en';
    for (const [key, { lat, lon }] of coordMap) {
      try {
        const result = await this.geocodingService.reverseGeocode(lat, lon, geoLang);
        addressMap.set(key, result.displayName);
      } catch {
        addressMap.set(key, `${lat.toFixed(5)}, ${lon.toFixed(5)}`);
      }
    }

    return {
      events: events.map((e) => {
        const coordKey = e.latitude != null && e.longitude != null
          ? `${e.latitude.toFixed(5)},${e.longitude.toFixed(5)}` : null;
        return {
          id: e.id,
          userId: e.userId,
          userName: (e as any).user?.displayName ?? '',
          userEmail: (e as any).user?.email ?? '',
          eventType: e.eventType,
          timestamp: e.timestamp.toISOString(),
          authMethod: e.authMethod,
          geoStatus: e.geoStatus,
          latitude: e.latitude,
          longitude: e.longitude,
          accuracy: e.accuracy,
          distanceToCheckpoint: e.distanceToCheckpoint,
          resolvedAddress: coordKey ? (addressMap.get(coordKey) ?? null) : null,
        };
      }),
      pagination: { total, page, pageSize },
    };
  }

  async exportEvents(checkpointId: string, date?: string, lang?: string) {
    const cp = await this.prisma.siteCheckpoint.findUnique({
      where: { id: checkpointId },
    });
    if (!cp) throw new NotFoundException('Checkpoint not found');

    const localDate = date ?? getLocalDate(cp.timezone);
    const events = await this.prisma.siteAttendanceEvent.findMany({
      where: { checkpointId, localDate },
      orderBy: { timestamp: 'asc' },
      include: { user: { select: { displayName: true, email: true } } },
    });

    // Batch reverse geocode for export
    const coordMap = new Map<string, { lat: number; lon: number }>();
    for (const e of events) {
      if (e.latitude != null && e.longitude != null) {
        const key = `${e.latitude.toFixed(5)},${e.longitude.toFixed(5)}`;
        if (!coordMap.has(key)) coordMap.set(key, { lat: e.latitude, lon: e.longitude });
      }
    }
    const addressMap = new Map<string, string>();
    const geoLang = lang === 'en' ? 'en,zh-CN,zh' : 'zh-CN,zh,en';
    for (const [key, { lat, lon }] of coordMap) {
      try {
        const result = await this.geocodingService.reverseGeocode(lat, lon, geoLang);
        addressMap.set(key, result.displayName);
      } catch {
        addressMap.set(key, `${lat.toFixed(5)}, ${lon.toFixed(5)}`);
      }
    }

    const timeFormatter = new Intl.DateTimeFormat('sv-SE', {
      timeZone: cp.timezone,
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit',
      second: '2-digit',
      hour12: false,
    });
    // 时区缩写后缀（PDT / PST / GMT+8 等）：
    //   1) 让 DST 切换从字面可见（PDT vs PST）
    //   2) Excel/WPS 不再把整列识别为 datetime，避免默认 cell format 把秒吞掉
    const tzAbbrFormatter = new Intl.DateTimeFormat('en-US', {
      timeZone: cp.timezone,
      timeZoneName: 'short',
    });
    const formatTime = (d: Date): string => {
      const tz = tzAbbrFormatter.formatToParts(d).find((p) => p.type === 'timeZoneName')!.value;
      return `${timeFormatter.format(d)} ${tz}`;
    };

    const header = `User,Email,Type,Time (${cp.timezone}),Auth,Geo Status,Distance(m),Accuracy(m),Location`;
    const rows = events.map((e) => {
      const user = (e as any).user;
      const coordKey = e.latitude != null && e.longitude != null
        ? `${e.latitude.toFixed(5)},${e.longitude.toFixed(5)}` : null;
      const location = coordKey ? (addressMap.get(coordKey) ?? '') : (cp.address ?? cp.name);
      return [
        `"${user?.displayName ?? ''}"`,
        user?.email ?? '',
        e.eventType,
        formatTime(e.timestamp),
        e.authMethod,
        e.geoStatus,
        e.distanceToCheckpoint?.toFixed(0) ?? '',
        e.accuracy?.toFixed(0) ?? '',
        `"${location}"`,
      ].join(',');
    });
    return [header, ...rows].join('\n');
  }

  async getTodaySummary(checkpointId: string) {
    const cp = await this.prisma.siteCheckpoint.findUnique({
      where: { id: checkpointId },
    });
    if (!cp) {
      throw new NotFoundException('Checkpoint not found');
    }

    const localDate = getLocalDate(cp.timezone);
    const [summaries, recentEvents, allEvents] = await Promise.all([
      this.summaryRepo.findByCheckpointAndDate(checkpointId, localDate),
      this.eventRepo.findByCheckpointAndDate(checkpointId, localDate, 1, 10),
      this.eventRepo.findAllByCheckpointAndDate(checkpointId, localDate),
    ]);

    const totalCheckedIn = summaries.filter((s) => s.checkInCount > 0).length;
    const totalCheckedOut = summaries.filter((s) => s.checkOutCount > 0).length;
    const notCheckedOut = summaries.filter(
      (s) => s.checkInCount > 0 && s.checkOutCount === 0,
    ).length;

    const eventsByUser = new Map<
      string,
      Array<{ eventType: SiteEventType; timestamp: Date }>
    >();
    for (const e of allEvents) {
      const arr = eventsByUser.get(e.userId) ?? [];
      arr.push({ eventType: e.eventType, timestamp: e.timestamp });
      eventsByUser.set(e.userId, arr);
    }
    const now = new Date();
    let totalDurationSeconds = 0;
    for (const events of eventsByUser.values()) {
      totalDurationSeconds += this.computeTodayDurationSeconds(events, now);
    }
    const avgDurationSeconds =
      eventsByUser.size > 0
        ? Math.floor(totalDurationSeconds / eventsByUser.size)
        : 0;

    return {
      date: localDate,
      totalCheckedIn,
      totalCheckedOut,
      notCheckedOut,
      totalDurationSeconds,
      avgDurationSeconds,
      recentEvents: recentEvents.map((e) => ({
        userId: e.userId,
        userName: (e as any).user?.displayName ?? '',
        eventType: e.eventType,
        timestamp: e.timestamp.toISOString(),
      })),
    };
  }

  // --- Private helpers ---

  private validateGeo(
    checkpoint: {
      geoPolicy: string;
      latitude: number;
      longitude: number;
      geoRadius: number;
      geoAccuracyThreshold: number;
    },
    dto: { latitude?: number; longitude?: number; accuracy?: number; geoStatus: string },
  ): { geoStatus: string; distance?: number; blocked: boolean } {
    if (checkpoint.geoPolicy === 'SKIP') {
      return { geoStatus: 'SKIPPED', blocked: false };
    }

    // If client already reported a non-VALID status (permission denied, unavailable, etc.)
    if (dto.geoStatus !== GeoStatus.VALID) {
      return { geoStatus: dto.geoStatus, blocked: true };
    }

    // Client says VALID — verify server-side
    if (dto.latitude == null || dto.longitude == null) {
      return { geoStatus: 'UNAVAILABLE', blocked: true };
    }

    // Check accuracy
    if (
      dto.accuracy != null &&
      dto.accuracy > checkpoint.geoAccuracyThreshold
    ) {
      return {
        geoStatus: 'LOW_ACCURACY',
        distance: calculateDistance(
          checkpoint.latitude,
          checkpoint.longitude,
          dto.latitude,
          dto.longitude,
        ),
        blocked: true,
      };
    }

    const distance = calculateDistance(
      checkpoint.latitude,
      checkpoint.longitude,
      dto.latitude,
      dto.longitude,
    );

    if (distance > checkpoint.geoRadius) {
      return { geoStatus: 'OUT_OF_RANGE', distance, blocked: true };
    }

    return { geoStatus: 'VALID', distance, blocked: false };
  }

  private async validateEventOrder(
    checkpointId: string,
    userId: string,
    localDate: string,
    eventType: string,
  ) {
    // Only fetch the latest event, not all events
    const lastEvent = await this.prisma.siteAttendanceEvent.findFirst({
      where: { checkpointId, userId, localDate },
      orderBy: { timestamp: 'desc' },
      select: { eventType: true },
    });

    if (!lastEvent) {
      if (eventType === 'CHECK_OUT') {
        throw new BadRequestException({
          code: SiteAttendanceErrorCodes.MUST_CHECK_IN_FIRST,
          message: 'You must check in before checking out',
        });
      }
      return;
    }
    if (lastEvent.eventType === eventType) {
      if (eventType === 'CHECK_IN') {
        throw new BadRequestException({
          code: SiteAttendanceErrorCodes.ALREADY_CHECKED_IN,
          message: 'Already checked in',
        });
      }
      throw new BadRequestException({
        code: SiteAttendanceErrorCodes.ALREADY_CHECKED_OUT,
        message: 'Already checked out',
      });
    }
  }

  /**
   * 30s 防"手抖连点"保护：查 30s 内**最新一条事件不限 type**，仅当跟当前提交
   * 是同 type 时才拦。
   *
   * 例（30s 内）：
   *   IN → IN          → 拦截（同 type 连点）
   *   IN → OUT → IN    → 通过（中间反向事件分隔，合法多周期）
   *   OUT → OUT        → 拦截
   *
   * 此前实现是查 30s 内"同 type 最近事件"，错杀多周期场景。详见 ERR-20260429-011。
   * 抛结构化错误（code + retryAfterSeconds），让前端按 code 选 i18n 文案 +
   * 显示精确倒计时。
   */
  private async checkDuplicateEvent(
    checkpointId: string,
    userId: string,
    eventType: string,
  ) {
    const thirtySecondsAgo = new Date(Date.now() - 30_000);
    const recent = await this.prisma.siteAttendanceEvent.findFirst({
      where: {
        checkpointId,
        userId,
        timestamp: { gte: thirtySecondsAgo },
      },
      orderBy: { timestamp: 'desc' },
      select: { eventType: true, timestamp: true },
    });
    if (recent && recent.eventType === eventType) {
      const elapsedMs = Date.now() - recent.timestamp.getTime();
      const retryAfterSeconds = Math.max(1, Math.ceil((30_000 - elapsedMs) / 1000));
      throw new BadRequestException({
        code: SiteAttendanceErrorCodes.CHECKIN_RATE_LIMITED,
        message: `Duplicate ${eventType} within 30s. Retry after ${retryAfterSeconds}s.`,
        retryAfterSeconds,
      });
    }
  }
}
