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

import {
  MeetingRequestStatus,
  MeetingStatus,
  Prisma,
} from '@/generated/prisma/client';
import { SystemNotificationsService } from '@/modules/system-notifications/system-notifications.service';
import {
  UploadService,
  type UploadedFileMetadata,
} from '@/modules/upload/upload.service';
import { PrismaService } from '@/prisma/prisma.service';
import { WorkingGroupIssuesService } from '@/modules/working-group-issues/working-group-issues.service';

// Stakeholder type id for ministries (matches the seeded "Ministry" type).
const MINISTRY_STAKEHOLDER_TYPE_ID = 1;
// Stakeholder type id for private-sector working groups.
const PRIVATE_SECTOR_STAKEHOLDER_TYPE_ID = 2;

import {
  ResendMailMeetingRequestsService,
  type SendMeetingRequestMailParams,
} from '../mail/resend-mail-meeting-requests.service';
import { AddMeetingRequestIssueDto } from './dto/add-meeting-request-issue.dto';
import { CreateMeetingRequestDto } from './dto/create-meeting-request.dto';
import { ListMeetingRequestsDto } from './dto/list-meeting-requests.dto';
import { UpdateMeetingRequestDto } from './dto/update-meeting-request.dto';

const MEETING_REQUEST_STATUS = {
  DRAFT: MeetingRequestStatus.DRAFT,
  SUBMITTED: MeetingRequestStatus.SUBMITTED,
  UNDER_REVIEW: MeetingRequestStatus.UNDER_REVIEW,
  SCHEDULED: MeetingRequestStatus.SCHEDULED,
  COMPLETED: MeetingRequestStatus.COMPLETED,
} as const;

const ISSUE_STATUS_CODE = {
  SAVED: 'SAVED',
  NEW_SUBMISSION: 'NEW_SUBMISSION',
} as const;

// How long the background ministry email may run before we give up on it.
const MINISTRY_EMAIL_TIMEOUT_MS = 20_000;
const MEETING_REQUEST_UPLOAD_FOLDER = 'meeting-requests';
const PDF_UPLOAD_OPTIONS = {
  allowedMediaTypes: ['pdf'],
  allowedMimeTypes: ['application/pdf'],
};

type EmailUser = {
  id: number;
  name: string | null;
  email: string;
  gmailEnabled: boolean;
};

type UserWithRoleLinks = {
  id: number;
  name: string | null;
  email: string | null;
  notificationSetting: {
    gmailEnabled: boolean;
  } | null;
  roles: {
    role: {
      name: string;
    } | null;
  }[];
};

type IssueAgencyForResponse = {
  agencyOrder: number;
  stakeholderId: number;
  stakeholder: unknown;
};

type IssueWithLegacyAgencyHistory = {
  governmentAgencies?: IssueAgencyForResponse[];
  issueResolves?: Array<{
    issueResolveAgencies: Array<{
      stakeholderId: number;
      stakeholder: unknown;
    }>;
  }>;
};

@Injectable()
export class MeetingRequestsService {
  private readonly logger = new Logger(MeetingRequestsService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly resendMailMeetingRequestsService: ResendMailMeetingRequestsService,
    private readonly systemNotificationsService: SystemNotificationsService,
    private readonly uploads: UploadService,
    // An optional constructor parameter (`?:`) loses its type at runtime, so
    // Nest cannot guess what to inject and would always pass undefined. The
    // explicit @Inject token is what makes this actually resolve.
    @Optional()
    @Inject(WorkingGroupIssuesService)
    private readonly workingGroupIssuesService?: WorkingGroupIssuesService,
  ) {}

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

    const { stakeholderIds, issueIds } = dto;
    const primaryAgencyId = issueIds?.length
      ? await this.getPrimaryAgencyIdForIssueScope(actorUserId)
      : undefined;

    if (!file) {
      throw new BadRequestException('Meeting request letter PDF is required');
    }

    const uploadedLetterPath = await this.saveMeetingRequestLetter(file);
    let storedInDatabase = false;

    try {
      const meetingRequest = await this.prisma.$transaction(async (tx) => {
        const createdRequest = await tx.meetingRequests.create({
          data: {
            title: dto.title,
            description: dto.description,

            userId: actorUserId,

            status: MEETING_REQUEST_STATUS.DRAFT,
            meetingRequestLetter: uploadedLetterPath,

            governmentAgencies: stakeholderIds?.length
              ? {
                  create: stakeholderIds.map((stakeholderId) => ({
                    stakeholderId,
                  })),
                }
              : undefined,
          },
        });

        if (issueIds?.length) {
          await this.attachIssuesToMeetingRequest(
            tx,
            createdRequest.id,
            issueIds,
            primaryAgencyId,
          );
        }

        return tx.meetingRequests.findFirst({
          where: {
            id: createdRequest.id,
            deletedAt: null,
          },
          include: this.includeRelations(),
        });
      });

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

      storedInDatabase = true;
      return this.addMeetingRequestLetterMetadata(meetingRequest);
    } catch (error) {
      if (!storedInDatabase) {
        await this.uploads.remove(uploadedLetterPath);
      }

      throw error;
    }
  }

  async findAll(query: ListMeetingRequestsDto = {}, currentUserId?: number) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 10;
    const skip = (page - 1) * limit;
    const where: Prisma.MeetingRequestsWhereInput = {
      deletedAt: null,
    };

    // If the current user belongs to a ministry, only show the meeting requests
    // that invited that ministry (it is one of the request's government
    // agencies). Other roles (e.g. admin) keep seeing every request.
    if (currentUserId) {
      const ministryStakeholderIds =
        await this.getUserMinistryStakeholderIds(currentUserId);

      if (ministryStakeholderIds.length > 0) {
        where.governmentAgencies = {
          some: {
            stakeholderId: { in: ministryStakeholderIds },
          },
        };
      }
    }

    const [data, total] = await this.prisma.$transaction([
      this.prisma.meetingRequests.findMany({
        where,
        include: this.includeRelations(),
        orderBy: {
          id: 'desc',
        },
        skip,
        take: limit,
      }),
      this.prisma.meetingRequests.count({ where }),
    ]);

    // The PSWG table needs to know whether the signed-in user belongs to the
    // same private-sector working group as each request creator. The UI uses
    // this flag to show management actions only to the owner working group.
    const dataWithMeetingCreationAccess = data.map((meetingRequest) => {
      const meetingDate =
        (meetingRequest.meetings ?? []).find(
          (meeting) => meeting.meetingDate !== null,
        )?.meetingDate ?? null;

      return {
        ...this.addMeetingCreationAccess(meetingRequest),
        meetingDate,
      };
    });

    const dataWithManagementAccess = currentUserId
      ? await this.addManagementAccess(
          dataWithMeetingCreationAccess,
          currentUserId,
        )
      : dataWithMeetingCreationAccess;

    const dataWithFileMetadata = await Promise.all(
      dataWithManagementAccess.map((meetingRequest) =>
        this.addMeetingRequestLetterMetadata(meetingRequest),
      ),
    );

    return {
      data: dataWithFileMetadata,
      meta: {
        page,
        limit,
        total,
        totalPages: total === 0 ? 0 : Math.ceil(total / limit),
      },
    };
  }

  // Return the ids of the ministry stakeholders the user belongs to (empty if
  // the user is not linked to any ministry, e.g. a private-sector or admin user).
  private async getUserMinistryStakeholderIds(
    userId: number,
  ): Promise<number[]> {
    const links = await this.prisma.stakeholderUser.findMany({
      where: { userId },
      select: {
        stakeholder: {
          select: { id: true, stakeholderTypeId: true, deletedAt: true },
        },
      },
    });

    return links
      .map((link) => link.stakeholder)
      .filter(
        (stakeholder) =>
          stakeholder &&
          stakeholder.deletedAt === null &&
          stakeholder.stakeholderTypeId === MINISTRY_STAKEHOLDER_TYPE_ID,
      )
      .map((stakeholder) => stakeholder.id);
  }

  private async getUserPrivateSectorStakeholderIds(
    userId: number,
  ): Promise<number[]> {
    const links = await this.prisma.stakeholderUser.findMany({
      where: { userId },
      select: {
        stakeholder: {
          select: {
            id: true,
            stakeholderTypeId: true,
            active: true,
            deletedAt: true,
          },
        },
      },
    });

    return links
      .map((link) => link.stakeholder)
      .filter(
        (stakeholder) =>
          stakeholder &&
          stakeholder.active &&
          stakeholder.deletedAt === null &&
          stakeholder.stakeholderTypeId === PRIVATE_SECTOR_STAKEHOLDER_TYPE_ID,
      )
      .map((stakeholder) => stakeholder.id);
  }

  private async addManagementAccess<
    T extends {
      userId: number;
    },
  >(meetingRequests: T[], actorUserId: number) {
    const actorStakeholderIds =
      await this.getUserPrivateSectorStakeholderIds(actorUserId);

    if (!actorStakeholderIds.length) {
      return meetingRequests.map((meetingRequest) => ({
        ...meetingRequest,
        canManage: false,
      }));
    }

    const requestOwnerIds = Array.from(
      new Set(meetingRequests.map((meetingRequest) => meetingRequest.userId)),
    );

    const ownerLinks = await this.prisma.stakeholderUser.findMany({
      where: {
        userId: {
          in: requestOwnerIds,
        },
      },
      select: {
        userId: true,
        stakeholder: {
          select: {
            id: true,
            stakeholderTypeId: true,
            active: true,
            deletedAt: true,
          },
        },
      },
    });

    const actorStakeholderIdSet = new Set(actorStakeholderIds);
    const ownerStakeholderIds = new Map<number, Set<number>>();

    for (const link of ownerLinks) {
      const stakeholder = link.stakeholder;

      if (
        !stakeholder ||
        !stakeholder.active ||
        stakeholder.deletedAt !== null ||
        stakeholder.stakeholderTypeId !== PRIVATE_SECTOR_STAKEHOLDER_TYPE_ID
      ) {
        continue;
      }

      const stakeholderIds = ownerStakeholderIds.get(link.userId) ?? new Set();
      stakeholderIds.add(stakeholder.id);
      ownerStakeholderIds.set(link.userId, stakeholderIds);
    }

    return meetingRequests.map((meetingRequest) => ({
      ...meetingRequest,
      canManage: Array.from(
        ownerStakeholderIds.get(meetingRequest.userId) ?? [],
      ).some((stakeholderId) => actorStakeholderIdSet.has(stakeholderId)),
    }));
  }

  async findOne(id: number) {
    const data = await this.prisma.meetingRequests.findFirst({
      where: {
        id,
        deletedAt: null,
      },
      include: this.includeRelations(),
    });

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

    return this.addMeetingRequestLetterMetadata(
      this.addMeetingCreationAccess(data),
    );
  }

  private async findEditable(id: number) {
    const data = await this.prisma.meetingRequests.findFirst({
      where: {
        id,
        deletedAt: null,
      },
    });

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

    if (data.status !== MEETING_REQUEST_STATUS.DRAFT) {
      throw new BadRequestException(
        'Only drafted meeting request can be edited.',
      );
    }

    return data;
  }

  async update(
    id: number,
    dto: UpdateMeetingRequestDto,
    actorUserId: number,
    file?: Express.Multer.File,
  ) {
    const editableRequest = await this.findEditable(id);

    await this.ensureRequestOwner(editableRequest.userId, actorUserId);

    const { stakeholderIds, issueIds } = dto;
    const primaryAgencyId = issueIds?.length
      ? await this.getPrimaryAgencyIdForIssueScope(actorUserId)
      : undefined;
    const uploadedLetterPath = file
      ? await this.saveMeetingRequestLetter(file)
      : undefined;
    let storedInDatabase = false;

    try {
      const updatedRequest = await this.prisma.$transaction(async (tx) => {
        await tx.meetingRequests.update({
          where: {
            id,
          },
          data: {
            ...(dto.title !== undefined && {
              title: dto.title,
            }),

            ...(dto.description !== undefined && {
              description: dto.description,
            }),

            ...(uploadedLetterPath && {
              meetingRequestLetter: uploadedLetterPath,
            }),

            governmentAgencies:
              stakeholderIds !== undefined
                ? {
                    deleteMany: {},
                    create: stakeholderIds.map((stakeholderId) => ({
                      stakeholderId,
                    })),
                  }
                : undefined,
          },
        });

        if (issueIds !== undefined) {
          await tx.issues.updateMany({
            where: {
              meetingRequestId: id,
              deletedAt: null,
            },
            data: {
              meetingRequestId: null,
            },
          });

          if (issueIds.length > 0) {
            await this.attachIssuesToMeetingRequest(
              tx,
              id,
              issueIds,
              primaryAgencyId,
            );
          }
        }

        return tx.meetingRequests.findFirst({
          where: {
            id,
            deletedAt: null,
          },
          include: this.includeRelations(),
        });
      });

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

      storedInDatabase = true;

      if (uploadedLetterPath) {
        await this.uploads.remove(
          this.normalizeStoredFilePath(editableRequest.meetingRequestLetter),
        );
      }

      return this.addMeetingRequestLetterMetadata(updatedRequest);
    } catch (error) {
      if (uploadedLetterPath && !storedInDatabase) {
        await this.uploads.remove(uploadedLetterPath);
      }

      throw error;
    }
  }

  async sendRequest(id: number, actorUserId: number) {
    const meetingRequest = await this.prisma.meetingRequests.findFirst({
      where: {
        id,
        deletedAt: null,
      },
      include: this.includeRelations(),
    });

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

    await this.ensureRequestOwner(meetingRequest.userId, actorUserId);

    if (meetingRequest.status !== MEETING_REQUEST_STATUS.DRAFT) {
      throw new BadRequestException(
        'Only drafted meeting request can be sent.',
      );
    }

    if (!meetingRequest.issues.length) {
      throw new BadRequestException(
        'At least one issue is required before sending a meeting request.',
      );
    }

    if (!meetingRequest.governmentAgencies.length) {
      throw new BadRequestException('Government agency is required.');
    }

    const ministryStakeholderIds = Array.from(
      new Set(
        meetingRequest.governmentAgencies
          .map((item) => item.stakeholderId)
          .filter(
            (stakeholderId): stakeholderId is number =>
              typeof stakeholderId === 'number' && stakeholderId > 0,
          ),
      ),
    );

    if (!ministryStakeholderIds.length) {
      throw new BadRequestException('Ministry stakeholder is required.');
    }

    const ministryName =
      meetingRequest.governmentAgencies
        .map((item) => item.stakeholder?.name)
        .filter((name): name is string => Boolean(name))
        .join(', ') || 'Ministry';

    const workingGroupName =
      meetingRequest.user?.name?.trim() || 'Private Sector';

    const workingGroupEmail =
      meetingRequest.user?.email?.trim() ||
      (await this.getUserEmailById(meetingRequest.userId))?.email ||
      null;

    const updatedRequest = await this.submitRequestAndLinkedIssues(id);

    const notificationResult =
      await this.systemNotificationsService.createForMeetingRequest({
        meetingRequestId: updatedRequest.id,
        meetingRequestTitle: updatedRequest.title,
        senderUserId: updatedRequest.userId,
        senderName: updatedRequest.user?.name?.trim() || workingGroupName,
        receiverStakeholderIds: ministryStakeholderIds,
      });

    const ministryUsers = await this.getUsersByStakeholderIds(
      ministryStakeholderIds,
    );

    const gmailEnabledMinistryUsers = ministryUsers.filter(
      (user) => user.gmailEnabled,
    );

    const ministryEmails = Array.from(
      new Set(gmailEnabledMinistryUsers.map((user) => user.email)),
    );

    this.logger.log(`Ministry users found: ${ministryUsers.length}`);

    this.logger.log(
      `Ministry users with Gmail notification enabled: ${gmailEnabledMinistryUsers.length}`,
    );

    this.logger.log(
      `Meeting request ${updatedRequest.id} Gmail recipients: ${
        ministryEmails.length ? ministryEmails.join(', ') : 'none'
      }`,
    );

    this.logger.log(
      `Meeting request ${updatedRequest.id} notification created: ${notificationResult.created}`,
    );

    this.logger.log(
      `Meeting request ${updatedRequest.id} notification skipped: ${notificationResult.skipped}`,
    );

    this.logger.log(
      `Meeting request ${updatedRequest.id} sender user ID: ${updatedRequest.userId}`,
    );

    this.logger.log(
      `Ministry stakeholders: ${ministryStakeholderIds.join(', ')}`,
    );

    // The email goes out in the background on purpose. Talking to Resend can
    // take longer than the reverse proxy is willing to wait, and when that
    // happened the browser lost the connection and showed "Failed to fetch"
    // even though the request had already been submitted. The database work
    // above is what the client actually needs, so answer as soon as it is done.
    void this.notifyMinistryByEmail({
      to: ministryEmails,
      replyTo: workingGroupEmail,
      requestId: updatedRequest.id,
      title: updatedRequest.title,
      description: updatedRequest.description,
      ministryName,
      workingGroupName,
      workingGroupEmail,
      meetingRequestLetter: updatedRequest.meetingRequestLetter,
      issues: updatedRequest.issues,
    });

    return {
      emailTo: ministryEmails,
      replyTo: workingGroupEmail,
      // The email has not been sent yet at this point, so there is no id to
      // report back. Kept in the response so its shape stays the same.
      resendId: null,
      notification: notificationResult,
      data: await this.addMeetingRequestLetterMetadata(updatedRequest),
    };
  }

  // Sends the ministry email without ever failing the meeting request. A
  // problem here is logged and nothing else: the request is already submitted
  // and the system notification was already created.
  private async notifyMinistryByEmail(
    params: SendMeetingRequestMailParams,
  ): Promise<void> {
    if (params.to.length === 0) {
      this.logger.warn(
        `No Ministry email found for meeting request ${params.requestId}. ` +
          'System Notification was created, but email was skipped.',
      );

      return;
    }

    try {
      const resendId = await this.withTimeout(
        this.resendMailMeetingRequestsService.sendMeetingRequestToMinistry(
          params,
        ),
        MINISTRY_EMAIL_TIMEOUT_MS,
      );

      this.logger.log(
        `Meeting request ${params.requestId} email sent: ${resendId ?? '-'}`,
      );
    } catch (error) {
      this.logger.error(
        `Meeting request ${params.requestId} email notification failed:`,
        error,
      );
    }
  }

  // Stops a hanging Resend call from leaving a promise pending forever.
  private withTimeout<T>(task: Promise<T>, timeoutMs: number): Promise<T> {
    let timer: NodeJS.Timeout | undefined;

    const timeout = new Promise<never>((_resolve, reject) => {
      timer = setTimeout(() => {
        reject(new Error(`Email sending timed out after ${timeoutMs}ms.`));
      }, timeoutMs);
    });

    return Promise.race([task, timeout]).finally(() => clearTimeout(timer));
  }

  async startReview(id: number, actorUserId: number) {
    const meetingRequest = await this.prisma.meetingRequests.findFirst({
      where: {
        id,
        deletedAt: null,
      },
      include: {
        governmentAgencies: {
          select: {
            stakeholderId: true,
          },
        },
      },
    });

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

    if (meetingRequest.status !== MEETING_REQUEST_STATUS.SUBMITTED) {
      throw new BadRequestException(
        'Only submitted meeting request can start review.',
      );
    }

    const ministryStakeholderIds =
      await this.getUserMinistryStakeholderIds(actorUserId);

    if (!ministryStakeholderIds.length) {
      throw new ForbiddenException('Only ministry users can start review.');
    }

    const canReview = meetingRequest.governmentAgencies.some((agency) =>
      ministryStakeholderIds.includes(agency.stakeholderId),
    );

    if (!canReview) {
      throw new ForbiddenException(
        'You are not assigned to this meeting request.',
      );
    }

    const updatedRequest = await this.prisma.meetingRequests.update({
      where: {
        id,
      },
      data: {
        status: MEETING_REQUEST_STATUS.UNDER_REVIEW,
      },
      include: this.includeRelations(),
    });

    return this.addMeetingRequestLetterMetadata(
      this.addMeetingCreationAccess(updatedRequest),
    );
  }

  // A Ministry adds one more issue to a meeting request that a working group
  // sent to it. The issue belongs to that working group, the Ministry is its
  // primary agency, and it always starts as "New Submission".
  async addMinistryIssue(
    meetingRequestId: number,
    actorUserId: number,
    dto: AddMeetingRequestIssueDto,
    attachmentFile?: Express.Multer.File,
  ) {
    if (!this.workingGroupIssuesService) {
      throw new BadRequestException('Issue creation is not available.');
    }

    const meetingRequest = await this.prisma.meetingRequests.findFirst({
      where: { id: meetingRequestId, deletedAt: null },
      select: {
        id: true,
        userId: true,
        governmentAgencies: { select: { stakeholderId: true } },
      },
    });

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

    // Only a Ministry the request was addressed to may add an issue, and that
    // same Ministry becomes the issue's primary (first) responsible agency.
    const ministryStakeholderIds =
      await this.getUserMinistryStakeholderIds(actorUserId);
    const primaryAgencyId = meetingRequest.governmentAgencies
      .map((agency) => agency.stakeholderId)
      .find((stakeholderId) => ministryStakeholderIds.includes(stakeholderId));

    if (!primaryAgencyId) {
      throw new ForbiddenException(
        'You are not assigned to this meeting request.',
      );
    }

    // The Ministry picks which working group owns the issue. When it does not
    // pick one, the group that raised the meeting request owns it. Either way
    // createForWorkingGroup checks the group is really a private-sector one.
    const [requestWorkingGroupId] =
      await this.getUserPrivateSectorStakeholderIds(meetingRequest.userId);
    const workingGroupId = dto.workingGroupId ?? requestWorkingGroupId;

    if (!workingGroupId) {
      throw new BadRequestException(
        'This meeting request has no working group to own the issue.',
      );
    }

    const issueStatusId = await this.getNewSubmissionIssueStatusId();

    // agencyOrder 1 is the primary agency; the optional extra agencies keep
    // the order the Ministry picked them in (second, third, ...).
    const governmentAgencies = [
      { stakeholderId: primaryAgencyId, agencyOrder: 1 },
      ...(dto.additionalAgencyIds ?? []).map((stakeholderId, index) => ({
        stakeholderId,
        agencyOrder: index + 2,
      })),
    ];

    // The working-group issues service already validates the category and the
    // agencies, stores the upload, and writes the agency rows.
    return this.workingGroupIssuesService.createForWorkingGroup(
      actorUserId,
      workingGroupId,
      {
        title: dto.title,
        description: dto.description,
        recommendation: dto.recommendation,
        categoryId: dto.categoryId,
        issueStatusId,
        meetingRequestId,
        governmentAgencies,
      },
      attachmentFile,
    );
  }

  // Every issue a Ministry adds starts in the "New Submission" status.
  private async getNewSubmissionIssueStatusId(): Promise<number> {
    const status = await this.prisma.issueStatuses.findFirst({
      where: {
        code: {
          equals: ISSUE_STATUS_CODE.NEW_SUBMISSION,
          mode: 'insensitive',
        },
        deletedAt: null,
      },
      select: { id: true },
    });

    if (!status) {
      throw new BadRequestException(
        'New Submission issue status was not found.',
      );
    }

    return status.id;
  }

  async remove(id: number, actorUserId: number) {
    const editableRequest = await this.findEditable(id);

    await this.ensureRequestOwner(editableRequest.userId, actorUserId);

    return this.prisma.meetingRequests.update({
      where: {
        id,
      },
      data: {
        deletedAt: new Date(),
      },
    });
  }

  private async ensureUserExists(userId: number) {
    const user = await this.prisma.user.findFirst({
      where: {
        id: userId,
        deletedAt: null,
        isActive: true,
      },
      select: {
        id: true,
      },
    });

    if (!user) {
      throw new BadRequestException('Current user was not found.');
    }
  }

  private async attachIssuesToMeetingRequest(
    tx: Prisma.TransactionClient,
    meetingRequestId: number,
    issueIds: number[],
    primaryAgencyId?: number,
  ) {
    const uniqueIssueIds = Array.from(new Set(issueIds));

    const where: Prisma.IssuesWhereInput = {
      id: {
        in: uniqueIssueIds,
      },
      deletedAt: null,
      issueStatus: {
        code: {
          equals: ISSUE_STATUS_CODE.SAVED,
          mode: 'insensitive',
        },
        deletedAt: null,
      },
      OR: [{ meetingRequestId: null }, { meetingRequestId }],
      ...(primaryAgencyId !== undefined
        ? {
            governmentAgencies: {
              some: {
                stakeholderId: primaryAgencyId,
                agencyOrder: 1,
              },
            },
          }
        : {}),
    };

    const result = await tx.issues.updateMany({
      where,
      data: {
        meetingRequestId,
      },
    });

    if (result.count !== uniqueIssueIds.length) {
      throw new BadRequestException(
        'One or more selected issues were not found or already belong to another meeting request.',
      );
    }
  }

  // PSWG users are scoped by their working group's related primary agency.
  // Users without a Private Sector stakeholder (for example super admins)
  // keep the existing broad meeting-request behavior.
  private async getPrimaryAgencyIdForIssueScope(
    userId: number,
  ): Promise<number | undefined> {
    if (!this.workingGroupIssuesService) {
      return undefined;
    }

    try {
      const primaryGovernment =
        await this.workingGroupIssuesService.getMyPrimaryGovernment(userId);

      return primaryGovernment.governmentAgency.id;
    } catch (error) {
      if (error instanceof ForbiddenException) {
        return undefined;
      }

      throw error;
    }
  }

  private async ensureRequestOwner(requestUserId: number, actorUserId: number) {
    const [requestOwnerStakeholderIds, actorStakeholderIds] = await Promise.all(
      [
        this.getUserPrivateSectorStakeholderIds(requestUserId),
        this.getUserPrivateSectorStakeholderIds(actorUserId),
      ],
    );

    const actorStakeholderIdSet = new Set(actorStakeholderIds);
    const isOwnerWorkingGroup = requestOwnerStakeholderIds.some(
      (stakeholderId) => actorStakeholderIdSet.has(stakeholderId),
    );

    if (!isOwnerWorkingGroup) {
      throw new ForbiddenException(
        'You can manage only meeting requests from your working group.',
      );
    }
  }

  private async submitRequestAndLinkedIssues(id: number) {
    return this.prisma.$transaction(async (tx) => {
      const newSubmissionStatus = await tx.issueStatuses.findFirst({
        where: {
          code: {
            equals: ISSUE_STATUS_CODE.NEW_SUBMISSION,
            mode: 'insensitive',
          },
          deletedAt: null,
        },
        select: {
          id: true,
        },
      });

      if (!newSubmissionStatus) {
        throw new BadRequestException(
          'New Submission issue status was not found.',
        );
      }

      await tx.meetingRequests.update({
        where: {
          id,
        },
        data: {
          status: MEETING_REQUEST_STATUS.SUBMITTED,
        },
      });

      await tx.issues.updateMany({
        where: {
          meetingRequestId: id,
          deletedAt: null,
        },
        data: {
          issueStatusId: newSubmissionStatus.id,
        },
      });

      const updatedRequest = await tx.meetingRequests.findFirst({
        where: {
          id,
          deletedAt: null,
        },
        include: this.includeRelations(),
      });

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

      return updatedRequest;
    });
  }

  private async getUserEmailById(userId: number): Promise<EmailUser | null> {
    const user = await this.prisma.user.findFirst({
      where: {
        id: userId,
        deletedAt: null,
        isActive: true,
      },
      select: {
        id: true,
        name: true,
        email: true,
        notificationSetting: {
          select: {
            gmailEnabled: true,
          },
        },
      },
    });

    if (!user) {
      return null;
    }

    const email = user.email?.trim().toLowerCase() || '';

    if (!this.isValidEmail(email)) {
      return null;
    }

    return {
      id: user.id,
      name: user.name ?? null,
      email,
      gmailEnabled: user.notificationSetting?.gmailEnabled === true,
    };
  }

  private async getUsersByStakeholderIds(
    stakeholderIds: number[],
  ): Promise<EmailUser[]> {
    const uniqueStakeholderIds = Array.from(
      new Set(
        stakeholderIds.filter(
          (stakeholderId) =>
            Number.isInteger(stakeholderId) && stakeholderId > 0,
        ),
      ),
    );

    if (!uniqueStakeholderIds.length) {
      return [];
    }

    const links = await this.prisma.stakeholderUser.findMany({
      where: {
        stakeholderId: {
          in: uniqueStakeholderIds,
        },
      },
      select: {
        userId: true,
      },
    });

    const userIds = Array.from(
      new Set(
        links
          .map((link) => link.userId)
          .filter(
            (userId): userId is number =>
              typeof userId === 'number' && userId > 0,
          ),
      ),
    );

    if (!userIds.length) {
      return [];
    }

    const users: UserWithRoleLinks[] = await this.prisma.user.findMany({
      where: {
        id: {
          in: userIds,
        },
        deletedAt: null,
        isActive: true,
      },
      select: {
        id: true,
        name: true,
        email: true,
        notificationSetting: {
          select: {
            gmailEnabled: true,
          },
        },
        roles: {
          select: {
            role: {
              select: {
                name: true,
              },
            },
          },
        },
      },
    });

    const validUsers: EmailUser[] = [];

    for (const user of users) {
      const hasMinistryRole = user.roles.some((roleLink) => {
        return roleLink.role?.name?.toLowerCase() === 'ministry';
      });

      if (!hasMinistryRole) {
        continue;
      }

      const email = user.email?.trim().toLowerCase() || '';

      if (!this.isValidEmail(email)) {
        continue;
      }

      validUsers.push({
        id: user.id,
        name: user.name ?? null,
        email,
        gmailEnabled: user.notificationSetting?.gmailEnabled === true,
      });
    }

    return validUsers;
  }

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

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

  private async saveMeetingRequestLetter(
    file: Express.Multer.File,
  ): Promise<string> {
    const upload = await this.uploads.save(
      file,
      MEETING_REQUEST_UPLOAD_FOLDER,
      PDF_UPLOAD_OPTIONS,
    );

    return upload.url;
  }

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

    if (!value) {
      return null;
    }

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

  private async addMeetingRequestLetterMetadata<
    T extends {
      meetingRequestLetter: string | null;
      issues?: IssueWithLegacyAgencyHistory[];
    },
  >(
    meetingRequest: T,
  ): Promise<
    Omit<T, 'meetingRequestLetter'> & {
      meetingRequestLetter: UploadedFileMetadata | null;
    }
  > {
    const { meetingRequestLetter, issues, ...requestData } = meetingRequest;
    const metadata = await this.uploads.getMetadata(
      this.normalizeStoredFilePath(meetingRequestLetter),
    );

    return {
      ...requestData,
      ...(issues
        ? {
            issues: issues.map((issue) => this.mergeLegacyIssueAgencies(issue)),
          }
        : {}),
      meetingRequestLetter: metadata,
    } as Omit<T, 'meetingRequestLetter'> & {
      meetingRequestLetter: UploadedFileMetadata | null;
    };
  }

  // Older meeting summaries stored supporting agencies on the resolution.
  // Merge them into the issue response so Meeting Requests can still display
  // all five agency slots. New saves use governmentAgencies directly.
  private mergeLegacyIssueAgencies<T extends IssueWithLegacyAgencyHistory>(
    issue: T,
  ) {
    const { issueResolves = [], ...issueData } = issue;
    const agenciesByOrder = new Map(
      (issue.governmentAgencies ?? []).map((agency) => [
        agency.agencyOrder,
        agency,
      ]),
    );
    const legacyAgencies = issueResolves[0]?.issueResolveAgencies ?? [];

    legacyAgencies.forEach((agency, index) => {
      const agencyOrder = index + 2;

      agenciesByOrder.set(agencyOrder, {
        agencyOrder,
        stakeholderId: agency.stakeholderId,
        stakeholder: agency.stakeholder,
      });
    });

    return {
      ...issueData,
      governmentAgencies: Array.from(agenciesByOrder.values()).sort(
        (firstAgency, secondAgency) =>
          firstAgency.agencyOrder - secondAgency.agencyOrder,
      ),
    };
  }

  private includeRelations() {
    return {
      user: {
        select: {
          id: true,
          email: true,
          name: true,
          position: true,
          avatar: true,
          isActive: true,
          deletedAt: true,
          createdAt: true,
          updatedAt: true,
          // The stakeholder(s) the requesting user belongs to, so the UI can
          // show "Requested By" as the stakeholder name (e.g. their working
          // group / ministry) instead of the personal user name.
          stakeholders: {
            select: {
              stakeholder: {
                select: {
                  id: true,
                  name: true,
                  logo: true,
                  coChair: true,
                },
              },
            },
          },
        },
      },

      governmentAgencies: {
        include: {
          stakeholder: true,
        },
      },

      issues: {
        where: {
          deletedAt: null,
        },
        include: {
          issueStatus: true,
          category: true,
          governmentAgencies: {
            orderBy: {
              agencyOrder: 'asc',
            },
            include: {
              stakeholder: true,
            },
          },
          issueResolves: {
            where: {
              deletedAt: null,
            },
            orderBy: {
              updatedAt: 'desc',
            },
            take: 1,
            select: {
              issueResolveAgencies: {
                orderBy: {
                  id: 'asc',
                },
                select: {
                  stakeholderId: true,
                  stakeholder: true,
                },
              },
            },
          },
        },
      },

      meetings: {
        where: {
          deletedAt: null,
        },
        orderBy: {
          meetingDate: 'desc',
        },
        select: {
          id: true,
          status: true,
          meetingDate: true,
        },
      },
    } as const;
  }

  private addMeetingCreationAccess<
    T extends {
      status: MeetingRequestStatus;
      meetings?: Array<{ status: MeetingStatus }>;
    },
  >(meetingRequest: T) {
    const { meetings = [], ...requestData } = meetingRequest;
    const hasUnfinishedMeeting = meetings.some(
      (meeting) => meeting.status !== MeetingStatus.COMPLETED,
    );
    const isFirstMeetingReady =
      meetingRequest.status === MeetingRequestStatus.UNDER_REVIEW &&
      meetings.length === 0;
    const arePreviousMeetingsCompleted =
      meetingRequest.status === MeetingRequestStatus.COMPLETED &&
      meetings.length > 0 &&
      !hasUnfinishedMeeting;

    return {
      ...requestData,
      canCreateMeeting: isFirstMeetingReady || arePreviousMeetingsCompleted,
    };
  }
}
