import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';

import {
  MeetingRequestStatus,
  MeetingStatus,
  Prisma,
} from '@/generated/prisma/client';
import { SystemNotificationsService } from '@/modules/system-notifications/system-notifications.service';
import { UploadService } from '@/modules/upload/upload.service';

import { CreateMeetingDto } from './dto/create-meeting.dto';
import { ListMeetingsDto } from './dto/list-meetings.dto';
import { UpdateMeetingDto } from './dto/update-meeting.dto';
import {
  MeetingRequestNotReadyToScheduleError,
  MeetingsRepository,
} from './meetings.repository';

type MeetingWithRelations = Awaited<ReturnType<MeetingsRepository['create']>>;

type MeetingRequestWithIssues = NonNullable<
  MeetingWithRelations['meetingRequest']
>;

type MeetingRequestIssue = MeetingRequestWithIssues['issues'][number];

type MeetingRequestAgency =
  MeetingRequestWithIssues['governmentAgencies'][number];

type IssueAgencySlot = {
  name: string;
  logo: string | null;
};

type RelatedStakeholder = Awaited<
  ReturnType<MeetingsRepository['findRelatedStakeholders']>
>[number];

type RelatedStakeholderLookup = {
  byId: Map<number, RelatedStakeholder>;
  byRelatedStakeholderId: Map<number, RelatedStakeholder[]>;
};

const CDC_GPSF_ROLE_NAME = 'cdc_g-psf';
const GUEST_ROLE_NAME = 'guest';
const MEETING_UPLOAD_FOLDER = 'meetings';
const PDF_UPLOAD_OPTIONS = { allowedMimeTypes: ['application/pdf'] };

@Injectable()
export class MeetingsService {
  constructor(
    private readonly meetings: MeetingsRepository,
    private readonly systemNotificationsService: SystemNotificationsService,
    private readonly uploads: UploadService,
  ) {}

  async create(
    dto: CreateMeetingDto,
    actorUserId: number,
    document?: Express.Multer.File,
  ) {
    await this.ensureUserExists(actorUserId);

    const meetingRequest = await this.ensureMeetingRequestCanBeManagedByUser(
      dto.meetingRequestId,
      actorUserId,
    );

    if (
      dto.meetingRequestId !== undefined &&
      !this.canCreateMeetingForRequest(meetingRequest)
    ) {
      throw new BadRequestException(
        'A meeting can be created only when the request is ready and all previous meetings are Completed.',
      );
    }

    this.ensureScheduledOrSubmittedMeetingHasDate(dto);
    this.ensureValidTimeRange(dto.startTime, dto.endTime);

    let documentReference: string | undefined;
    let meeting: MeetingWithRelations;

    try {
      documentReference = await this.saveMeetingDocument(document);
      meeting = await this.createMeeting(
        this.buildCreateData(dto, actorUserId, documentReference),
        dto.meetingRequestId,
        dto.status === MeetingStatus.SCHEDULED,
      );
    } catch (error) {
      await this.uploads.remove(documentReference);
      throw error;
    }

    if (dto.sendInvites !== false) {
      await this.notifyPswgWhenMeetingScheduled(meeting, actorUserId);
      await this.notifyCdcGpsfWhenMeetingScheduled(meeting, actorUserId);
    }

    const stakeholderLookup = await this.getRelatedStakeholderLookup([meeting]);

    return {
      message: 'Meeting created successfully.',
      meeting: await this.addMeetingSummary(meeting, stakeholderLookup),
    };
  }

  async findAll(query: ListMeetingsDto = {}, currentUserId?: number) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 10;
    const scope = await this.buildVisibilityScope(currentUserId);

    const result = await this.meetings.findMany(
      {
        ...query,
        page,
        limit,
      },
      scope,
    );

    const stakeholderLookup = await this.getRelatedStakeholderLookup(
      result.data,
    );

    return {
      items: await Promise.all(
        result.data.map((meeting) =>
          this.addMeetingSummary(meeting, stakeholderLookup),
        ),
      ),
      meta: {
        page,
        limit,
        total: result.total,
        totalPages: Math.ceil(result.total / limit),
      },
    };
  }

  async findOne(id: number, currentUserId?: number) {
    const scope = await this.buildVisibilityScope(currentUserId);
    const meeting = await this.ensureMeetingExists(id, scope);

    const stakeholderLookup = await this.getRelatedStakeholderLookup([meeting]);

    return this.addMeetingSummary(meeting, stakeholderLookup);
  }

  async findMeetingRequestGuestUsers(
    meetingRequestId: number,
    actorUserId: number,
  ) {
    await this.ensureMeetingRequestCanBeManagedByUser(
      meetingRequestId,
      actorUserId,
    );

    const users =
      await this.meetings.findActiveUsersByRoleName(GUEST_ROLE_NAME);

    return users
      .filter((user) => this.isValidEmail(user.email))
      .map((user) => ({
        id: user.id,
        name: user.name,
        email: user.email,
        avatar: user.avatar,
        position: user.position,
      }));
  }

  async update(
    id: number,
    dto: UpdateMeetingDto,
    actorUserId: number,
    document?: Express.Multer.File,
  ) {
    const scope = await this.buildVisibilityScope(actorUserId);
    const currentMeeting = await this.ensureMeetingExists(id, scope);

    if (
      currentMeeting.status !== MeetingStatus.DRAFT &&
      !(
        currentMeeting.status === MeetingStatus.SCHEDULED &&
        currentMeeting.meetingSummaries?.length === 0
      ) &&
      this.isPastMeetingDate(currentMeeting.meetingDate)
    ) {
      throw new BadRequestException(
        'A Scheduled meeting cannot be edited after its Meeting Summary is created.',
      );
    }

    const nextMeetingRequestId =
      dto.meetingRequestId ?? currentMeeting.meetingRequestId ?? undefined;

    await this.ensureMeetingRequestCanBeManagedByUser(
      nextMeetingRequestId,
      actorUserId,
    );

    this.ensureScheduledOrSubmittedMeetingUpdateHasDate(
      dto,
      currentMeeting.meetingDate,
      currentMeeting.status,
    );

    this.ensureValidTimeRange(dto.startTime, dto.endTime);

    const previousStatus = currentMeeting.status;

    const meetingRequestIdToSchedule =
      dto.status === MeetingStatus.SCHEDULED &&
      previousStatus !== MeetingStatus.SCHEDULED
        ? nextMeetingRequestId
        : undefined;

    let documentReference: string | undefined;
    let meeting: MeetingWithRelations;

    try {
      documentReference = await this.saveMeetingDocument(document);
      meeting = await this.updateMeeting(
        id,
        this.buildUpdateData(dto, documentReference),
        meetingRequestIdToSchedule,
      );
    } catch (error) {
      await this.uploads.remove(documentReference);
      throw error;
    }

    if (documentReference) {
      await this.uploads.remove(
        this.normalizeStoredFilePath(currentMeeting.documentReference),
      );
    }

    const currentStatus = meeting.status;

    const meetingBecameScheduled =
      previousStatus !== MeetingStatus.SCHEDULED &&
      currentStatus === MeetingStatus.SCHEDULED;

    const meetingRequestWasAttached =
      !currentMeeting.meetingRequestId &&
      Boolean(meeting.meetingRequestId) &&
      currentStatus === MeetingStatus.SCHEDULED;

    const scheduleWasChanged =
      currentStatus === MeetingStatus.SCHEDULED &&
      (dto.title !== undefined ||
        dto.meetingDate !== undefined ||
        dto.startTime !== undefined ||
        dto.endTime !== undefined ||
        dto.location !== undefined);

    if (
      dto.sendInvites !== false &&
      (meetingBecameScheduled ||
        meetingRequestWasAttached ||
        scheduleWasChanged)
    ) {
      await this.notifyPswgWhenMeetingScheduled(meeting, actorUserId);
      await this.notifyCdcGpsfWhenMeetingScheduled(meeting, actorUserId);
    }

    const stakeholderLookup = await this.getRelatedStakeholderLookup([meeting]);

    return {
      message: 'Meeting updated successfully.',
      meeting: await this.addMeetingSummary(meeting, stakeholderLookup),
    };
  }

  async remove(id: number, actorUserId?: number) {
    const scope = await this.buildVisibilityScope(actorUserId);
    await this.ensureMeetingExists(id, scope);

    await this.meetings.softDelete(id);

    return {
      message: 'Meeting deleted successfully.',
    };
  }

  private async notifyPswgWhenMeetingScheduled(
    meeting: MeetingWithRelations,
    actorUserId: number,
  ) {
    if (meeting.status !== MeetingStatus.SCHEDULED) {
      return;
    }

    const meetingRequestId = Number(meeting.meetingRequestId);

    if (!Number.isInteger(meetingRequestId) || meetingRequestId <= 0) {
      return;
    }

    const [meetingRequest, actorMinistryStakeholderIds] = await Promise.all([
      this.meetings.findMeetingRequestAgencyIds(meetingRequestId),
      this.meetings.findMinistryStakeholderIdsByUserId(actorUserId),
    ]);

    if (!meetingRequest || actorMinistryStakeholderIds.length === 0) {
      return;
    }

    const requestStakeholderIds = this.uniqueNumbers(
      meetingRequest.governmentAgencies.map((agency) => agency.stakeholderId),
    );

    const targetMinistryStakeholderIds = actorMinistryStakeholderIds.filter(
      (stakeholderId) => requestStakeholderIds.includes(stakeholderId),
    );

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

    const workingGroupUserLinks =
      await this.meetings.findWorkingGroupUsersByMinistryStakeholderIds(
        targetMinistryStakeholderIds,
      );

    const recipients = Array.from(
      new Map(
        workingGroupUserLinks
          .map((link) => link.user)
          .filter(
            (user) =>
              user.isActive &&
              !user.deletedAt &&
              Number.isInteger(user.id) &&
              user.id > 0,
          )
          .map((user) => [user.id, user]),
      ).values(),
    );

    await Promise.all(
      recipients.map((recipient) =>
        this.systemNotificationsService.createForPswgMeetingScheduled({
          meetingId: meeting.id,
          meetingRequestId,
          meetingTitle: meeting.title,
          senderUserId: meeting.userId,
          senderName: meeting.user?.name?.trim() || 'Ministry',
          receiverUserId: recipient.id,
          meetingDate: meeting.meetingDate,
          startTime: meeting.startTime,
          endTime: meeting.endTime,
          location: meeting.location,
        }),
      ),
    );
  }

  private async notifyCdcGpsfWhenMeetingScheduled(
    meeting: MeetingWithRelations,
    actorUserId: number,
  ) {
    if (meeting.status !== MeetingStatus.SCHEDULED) {
      return;
    }

    const meetingRequestId = Number(meeting.meetingRequestId);
    const validMeetingRequestId =
      Number.isInteger(meetingRequestId) && meetingRequestId > 0
        ? meetingRequestId
        : null;

    const recipients =
      await this.meetings.findActiveUsersByRoleName(CDC_GPSF_ROLE_NAME);

    await Promise.all(
      recipients.map((recipient) =>
        this.systemNotificationsService.createForCdcGpsfMeetingScheduled({
          meetingId: meeting.id,
          meetingRequestId: validMeetingRequestId,
          meetingTitle: meeting.title,
          senderUserId: actorUserId,
          senderName: meeting.user?.name?.trim() || 'Ministry',
          receiverUserId: recipient.id,
          meetingDate: meeting.meetingDate,
          startTime: meeting.startTime,
          endTime: meeting.endTime,
          location: meeting.location,
        }),
      ),
    );
  }

  private async createMeeting(
    data: Parameters<MeetingsRepository['create']>[0],
    meetingRequestId?: number,
    scheduleMeetingRequest = false,
  ) {
    try {
      return await this.meetings.create(
        data,
        meetingRequestId,
        scheduleMeetingRequest,
      );
    } catch (error) {
      this.throwScheduleTransitionError(error);
    }
  }

  private async updateMeeting(
    id: number,
    data: Parameters<MeetingsRepository['update']>[1],
    meetingRequestIdToSchedule?: number,
  ) {
    try {
      return await this.meetings.update(id, data, meetingRequestIdToSchedule);
    } catch (error) {
      this.throwScheduleTransitionError(error);
    }
  }

  private throwScheduleTransitionError(error: unknown): never {
    if (error instanceof MeetingRequestNotReadyToScheduleError) {
      throw new BadRequestException(error.message);
    }

    throw error;
  }

  private async ensureMeetingRequestCanBeManagedByUser(
    meetingRequestId: number | undefined,
    actorUserId: number,
  ) {
    if (meetingRequestId === undefined) {
      return;
    }

    const ministryStakeholderIds =
      await this.meetings.findMinistryStakeholderIdsByUserId(actorUserId);

    if (ministryStakeholderIds.length === 0) {
      throw new ForbiddenException(
        'Your account is not assigned to a Ministry stakeholder.',
      );
    }

    const meetingRequest =
      await this.meetings.findMeetingRequestForStakeholderIds(
        meetingRequestId,
        ministryStakeholderIds,
      );

    if (!meetingRequest) {
      throw new ForbiddenException(
        'This Meeting Request is not addressed to your Ministry.',
      );
    }

    return meetingRequest;
  }

  private canCreateMeetingForRequest(
    meetingRequest:
      | Awaited<
          ReturnType<MeetingsRepository['findMeetingRequestForStakeholderIds']>
        >
      | undefined,
  ) {
    if (!meetingRequest) {
      return false;
    }

    const hasUnfinishedMeeting = meetingRequest.meetings.some(
      (meeting) => meeting.status !== MeetingStatus.COMPLETED,
    );
    const isFirstMeetingReady =
      meetingRequest.status === MeetingRequestStatus.UNDER_REVIEW &&
      meetingRequest.meetings.length === 0;
    const arePreviousMeetingsCompleted =
      meetingRequest.status === MeetingRequestStatus.COMPLETED &&
      meetingRequest.meetings.length > 0 &&
      !hasUnfinishedMeeting;

    return isFirstMeetingReady || arePreviousMeetingsCompleted;
  }

  private async ensureMeetingExists(
    id: number,
    scope?: Prisma.MeetingWhereInput,
  ) {
    const meeting = await this.meetings.findById(id, scope);

    if (!meeting) {
      throw new NotFoundException('Meeting not found');
    }

    return meeting;
  }

  // Ministry users only see meetings for requests addressed to one of their
  // Ministry stakeholders. A manually created meeting without a request is
  // visible only to the user who created it.
  private async buildVisibilityScope(
    currentUserId?: number,
  ): Promise<Prisma.MeetingWhereInput | undefined> {
    if (!currentUserId) {
      return undefined;
    }

    const ministryStakeholderIds =
      await this.meetings.findMinistryStakeholderIdsByUserId(currentUserId);

    if (ministryStakeholderIds.length === 0) {
      return undefined;
    }

    return {
      OR: [
        {
          meetingRequest: {
            governmentAgencies: {
              some: {
                stakeholderId: { in: ministryStakeholderIds },
              },
            },
          },
        },
        {
          meetingRequestId: null,
          userId: currentUserId,
        },
      ],
    };
  }

  private async addMeetingSummary(
    meeting: MeetingWithRelations,
    stakeholderLookup: RelatedStakeholderLookup,
  ) {
    const { meetingSummaries, documentReference, ...meetingData } = meeting;
    const workingGroupName = meeting.meetingRequest
      ? this.getWorkingGroupName(meeting.meetingRequest, stakeholderLookup)
      : '-';

    const issueCount = meeting.meetingRequest?.issues.length ?? 0;

    const meetingRequest = meeting.meetingRequest
      ? this.formatMeetingRequest(meeting.meetingRequest)
      : null;

    return {
      ...meetingData,
      documentReference: await this.uploads.getMetadata(
        this.normalizeStoredFilePath(documentReference),
      ),
      meetingRequest,
      workingGroupName,
      issueCount,
      meetingSummaryId: meetingSummaries[0]?.id ?? null,
    };
  }

  private formatMeetingRequest(meetingRequest: MeetingRequestWithIssues) {
    // The stakeholder (working group / ministry) the requesting user belongs to.
    const requesterStakeholder = meetingRequest.user?.stakeholders?.find(
      (link) => link.stakeholder?.name,
    )?.stakeholder;

    const stakeholderName = requesterStakeholder?.name?.trim();
    const requesterName = meetingRequest.user?.name?.trim();

    return {
      ...meetingRequest,
      // Computed fields the detail/print views rely on (the raw model does not
      // store these):
      // - requestedBy / privateSectorWG: the requester's stakeholder name.
      // - submittedBy: the requester's personal name.
      // - requestedDate: when the request was last submitted.
      requestedBy: stakeholderName || requesterName || null,
      privateSectorWG: stakeholderName || requesterName || null,
      submittedBy: requesterName || null,
      requestedDate: (
        meetingRequest.updatedAt ?? meetingRequest.createdAt
      )?.toISOString(),
      issues: meetingRequest.issues.map((issue) =>
        this.formatMeetingRequestIssue(
          issue,
          meetingRequest.governmentAgencies,
        ),
      ),
    };
  }

  private formatMeetingRequestIssue(
    issue: MeetingRequestIssue,
    meetingRequestAgencies: MeetingRequestAgency[],
  ) {
    const primaryAgency = this.getIssueAgencySlot(
      issue,
      meetingRequestAgencies,
      1,
    );

    const secondAgency = this.getIssueAgencySlot(
      issue,
      meetingRequestAgencies,
      2,
    );

    const thirdAgency = this.getIssueAgencySlot(
      issue,
      meetingRequestAgencies,
      3,
    );

    const fourthAgency = this.getIssueAgencySlot(
      issue,
      meetingRequestAgencies,
      4,
    );

    const fifthAgency = this.getIssueAgencySlot(
      issue,
      meetingRequestAgencies,
      5,
    );

    return {
      ...issue,
      issue: issue.title,
      category: issue.category?.name ?? '-',
      status: issue.issueStatus?.name ?? '-',
      primaryAgency: primaryAgency.name,
      primaryAgencyLogo: primaryAgency.logo,
      secondAgency: secondAgency.name,
      secondAgencyLogo: secondAgency.logo,
      thirdAgency: thirdAgency.name,
      thirdAgencyLogo: thirdAgency.logo,
      fourthAgency: fourthAgency.name,
      fourthAgencyLogo: fourthAgency.logo,
      fifthAgency: fifthAgency.name,
      fifthAgencyLogo: fifthAgency.logo,
    };
  }

  private getIssueAgencySlot(
    issue: MeetingRequestIssue,
    meetingRequestAgencies: MeetingRequestAgency[],
    agencyOrder: number,
  ): IssueAgencySlot {
    const issueAgency = issue.governmentAgencies.find(
      (agency) => agency.agencyOrder === agencyOrder,
    );

    // A meeting request does not store an order for its agencies. Only use
    // its first agency as the primary fallback; never assign that agency to
    // a secondary slot just because the issue relation is missing.
    const fallbackAgency = agencyOrder === 1 ? meetingRequestAgencies[0] : null;

    const stakeholder =
      issueAgency?.stakeholder ?? fallbackAgency?.stakeholder ?? null;

    return {
      name: stakeholder?.name ?? 'Not Uploaded',
      logo: stakeholder?.logo ?? null,
    };
  }

  private getWorkingGroupName(
    meetingRequest: MeetingRequestWithIssues,
    stakeholderLookup: RelatedStakeholderLookup,
  ) {
    return (
      this.getWorkingGroupNames(meetingRequest, stakeholderLookup)[0] ?? '-'
    );
  }

  private getWorkingGroupNames(
    meetingRequest: MeetingRequestWithIssues,
    stakeholderLookup: RelatedStakeholderLookup,
  ) {
    const agencies = meetingRequest.governmentAgencies
      .map((item) => item.stakeholder)
      .filter((stakeholder): stakeholder is NonNullable<typeof stakeholder> =>
        Boolean(stakeholder),
      );

    const directWorkingGroupNames = agencies
      .filter((stakeholder) => this.isWorkingGroupStakeholder(stakeholder))
      .map((stakeholder) => stakeholder.name.trim())
      .filter(Boolean);

    if (directWorkingGroupNames.length > 0) {
      return this.uniqueNames(directWorkingGroupNames);
    }

    const relatedWorkingGroupNames: string[] = [];

    for (const agency of agencies) {
      if (agency.relatedStakeholderId) {
        const relatedStakeholder = stakeholderLookup.byId.get(
          agency.relatedStakeholderId,
        );

        if (
          relatedStakeholder &&
          this.isWorkingGroupStakeholder(relatedStakeholder)
        ) {
          relatedWorkingGroupNames.push(relatedStakeholder.name);
        }
      }

      const relatedStakeholders =
        stakeholderLookup.byRelatedStakeholderId.get(agency.id) ?? [];

      for (const relatedStakeholder of relatedStakeholders) {
        if (this.isWorkingGroupStakeholder(relatedStakeholder)) {
          relatedWorkingGroupNames.push(relatedStakeholder.name);
        }
      }
    }

    if (relatedWorkingGroupNames.length > 0) {
      return this.uniqueNames(relatedWorkingGroupNames);
    }

    const nameFromRequestUser = meetingRequest.user?.name?.trim();

    return nameFromRequestUser ? [nameFromRequestUser] : [];
  }

  private async getRelatedStakeholderLookup(meetings: MeetingWithRelations[]) {
    const agencies = meetings.flatMap(
      (meeting) =>
        meeting.meetingRequest?.governmentAgencies
          .map((item) => item.stakeholder)
          .filter(
            (stakeholder): stakeholder is NonNullable<typeof stakeholder> =>
              Boolean(stakeholder),
          ) ?? [],
    );

    const agencyIds = this.uniqueNumbers(agencies.map((agency) => agency.id));

    const relatedStakeholderIds = this.uniqueNumbers(
      agencies
        .map((agency) => agency.relatedStakeholderId)
        .filter((id): id is number => typeof id === 'number'),
    );

    const relatedStakeholders = await this.meetings.findRelatedStakeholders(
      agencyIds,
      relatedStakeholderIds,
    );

    const byId = new Map<number, RelatedStakeholder>();
    const byRelatedStakeholderId = new Map<number, RelatedStakeholder[]>();

    for (const stakeholder of relatedStakeholders) {
      byId.set(stakeholder.id, stakeholder);

      if (stakeholder.relatedStakeholderId) {
        const current =
          byRelatedStakeholderId.get(stakeholder.relatedStakeholderId) ?? [];

        byRelatedStakeholderId.set(stakeholder.relatedStakeholderId, [
          ...current,
          stakeholder,
        ]);
      }
    }

    return {
      byId,
      byRelatedStakeholderId,
    };
  }

  private isWorkingGroupStakeholder(stakeholder: {
    stakeholderTypeId?: number | null;
    stakeholderType?: { name?: string | null } | null;
  }) {
    const stakeholderTypeName =
      stakeholder.stakeholderType?.name?.toLowerCase() ?? '';

    return (
      stakeholder.stakeholderTypeId === 2 ||
      stakeholderTypeName.includes('private') ||
      stakeholderTypeName.includes('working')
    );
  }

  private uniqueNames(names: string[]) {
    return Array.from(
      new Set(names.map((name) => name.trim()).filter(Boolean)),
    );
  }

  private uniqueNumbers(values: number[]) {
    return Array.from(new Set(values));
  }

  private isValidEmail(value?: string | null): value is string {
    if (!value) {
      return false;
    }

    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
  }

  private async ensureUserExists(id: number) {
    const user = await this.meetings.findUserById(id);

    if (!user) {
      throw new BadRequestException('User not found');
    }
  }

  private ensureValidTimeRange(startTime?: string, endTime?: string) {
    if (!startTime || !endTime) {
      return;
    }

    if (endTime <= startTime) {
      throw new BadRequestException('End time must be after start time');
    }
  }

  private ensureScheduledOrSubmittedMeetingHasDate(dto: CreateMeetingDto) {
    if ((dto.status ?? MeetingStatus.DRAFT) === MeetingStatus.DRAFT) {
      return;
    }

    if (!dto.meetingDate) {
      throw new BadRequestException('Meeting date is required');
    }
  }

  private isPastMeetingDate(meetingDate: Date | null) {
    if (!meetingDate) {
      return false;
    }

    const meetingDateKey = meetingDate.toISOString().slice(0, 10);
    const cambodiaDateParts = new Intl.DateTimeFormat('en-US', {
      timeZone: 'Asia/Phnom_Penh',
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
    }).formatToParts(new Date());

    const year = cambodiaDateParts.find((part) => part.type === 'year')?.value;
    const month = cambodiaDateParts.find(
      (part) => part.type === 'month',
    )?.value;
    const day = cambodiaDateParts.find((part) => part.type === 'day')?.value;
    const todayDateKey = `${year}-${month}-${day}`;

    return meetingDateKey < todayDateKey;
  }

  private ensureScheduledOrSubmittedMeetingUpdateHasDate(
    dto: UpdateMeetingDto,
    currentMeetingDate: Date | null,
    currentStatus?: MeetingStatus | null,
  ) {
    const nextStatus =
      dto.status !== undefined
        ? dto.status
        : (currentStatus ?? MeetingStatus.DRAFT);

    const nextMeetingDate =
      dto.meetingDate !== undefined
        ? this.toDate(dto.meetingDate)
        : currentMeetingDate;

    if (nextStatus === MeetingStatus.DRAFT) {
      return;
    }

    if (!nextMeetingDate) {
      throw new BadRequestException('Meeting date is required');
    }
  }

  private async saveMeetingDocument(file?: Express.Multer.File) {
    if (!file) {
      return undefined;
    }

    const uploadedFile = await this.uploads.save(
      file,
      MEETING_UPLOAD_FOLDER,
      PDF_UPLOAD_OPTIONS,
    );

    return uploadedFile.url;
  }

  private normalizeStoredFilePath(filePath: string | null | undefined) {
    const value = filePath?.trim();

    if (!value) {
      return null;
    }

    const withoutDot = value.startsWith('./') ? value.slice(2) : value;

    return withoutDot.startsWith('uploads/') ? `/${withoutDot}` : withoutDot;
  }

  private buildCreateData(
    dto: CreateMeetingDto,
    actorUserId: number,
    documentReference?: string,
  ) {
    return {
      title: dto.title.trim(),
      description: dto.description?.trim() || null,
      meetingDate: this.toDate(dto.meetingDate),
      startTime: this.toTime(dto.startTime),
      endTime: this.toTime(dto.endTime),
      location: dto.location?.trim() || null,
      documentReference: documentReference ?? null,
      status: dto.status ?? MeetingStatus.DRAFT,
      user: {
        connect: {
          id: actorUserId,
        },
      },
      meetingRequest: dto.meetingRequestId
        ? {
            connect: {
              id: dto.meetingRequestId,
            },
          }
        : undefined,
      guests: this.buildGuestRelation(dto.guestEmails, dto.guestUserIds),
    };
  }

  private buildUpdateData(dto: UpdateMeetingDto, documentReference?: string) {
    return {
      ...(dto.title !== undefined && {
        title: dto.title.trim(),
      }),
      ...(dto.description !== undefined && {
        description: dto.description?.trim() || null,
      }),
      ...(dto.meetingDate !== undefined && {
        meetingDate: this.toDate(dto.meetingDate),
      }),
      ...(dto.startTime !== undefined && {
        startTime: this.toTime(dto.startTime),
      }),
      ...(dto.endTime !== undefined && {
        endTime: this.toTime(dto.endTime),
      }),
      ...(dto.location !== undefined && {
        location: dto.location?.trim() || null,
      }),
      ...(documentReference && {
        documentReference,
      }),
      ...(dto.status !== undefined && {
        status: dto.status,
      }),
      ...(dto.meetingRequestId !== undefined && {
        meetingRequest: {
          connect: {
            id: dto.meetingRequestId,
          },
        },
      }),
      ...(dto.guestEmails !== undefined || dto.guestUserIds !== undefined
        ? {
            guests: {
              deleteMany: {},
              create: this.buildGuestCreateData(
                dto.guestEmails,
                dto.guestUserIds,
              ),
            },
          }
        : {}),
    };
  }

  private buildGuestRelation(emails?: string[], userIds?: number[]) {
    const guests = this.buildGuestCreateData(emails, userIds);

    if (!guests.length) {
      return undefined;
    }

    return {
      create: guests,
    };
  }

  private buildGuestCreateData(emails?: string[], userIds?: number[]) {
    const guestEmails = (emails ?? [])
      .map((email) => email.trim())
      .filter(Boolean)
      .map((email) => ({
        email,
      }));

    const guestUsers = (userIds ?? []).map((userId) => ({
      userId,
    }));

    return [...guestEmails, ...guestUsers];
  }

  private toDate(value?: string | null) {
    if (!value) {
      return null;
    }

    return new Date(`${value}T00:00:00.000Z`);
  }

  private toTime(value?: string) {
    if (!value) {
      return null;
    }

    return new Date(`1970-01-01T${value}:00.000Z`);
  }
}
