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

import { ExcelExportService } from '@/common/excel/excel-export.service';
import { 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 { CreateMeetingSummaryDto } from './dto/create-meeting-summary.dto';
import { IssueResolveInputDto } from './dto/issue-resolve-input.dto';
import { ListMeetingSummariesDto } from './dto/list-meeting-summaries.dto';
import { MeetingSummaryStatusCode } from './dto/meeting-summary-status-code';
import { UpdateMeetingSummaryDto } from './dto/update-meeting-summary.dto';
import { UploadIssueResolveDto } from './dto/upload-issue-resolve.dto';
import {
  MeetingRequestNotReadyToCompleteError,
  type IssueGovernmentAgencyInput,
  MeetingSummaryParticipantData,
  MeetingSummaryRepository,
} from './meeting-summary.repository';

// The summary status codes, mirroring the MeetingSummaryStatus lookup table.
// The API sends/receives the code as a flat string.
export type MeetingSummaryStatus = MeetingSummaryStatusCode;

// The status a brand-new summary lands in when none is supplied.
const DEFAULT_STATUS_CODE: MeetingSummaryStatus = 'DRAFT';
const ISSUE_ESCALATION_TARGETS = ['CDC', 'CEFP'] as const;
const CDC_GPSF_ROLE_NAME = 'cdc_g-psf';
const DUPLICATE_MEETING_SUMMARY_MESSAGE =
  'This meeting already has a meeting summary.';
const MEETING_SUMMARY_UPLOAD_FOLDER = 'meeting-summaries';
const PDF_UPLOAD_OPTIONS = {
  allowedMimeTypes: ['application/pdf'],
};

type IssueEscalationTarget = (typeof ISSUE_ESCALATION_TARGETS)[number];

// One ordered agency on an issue.
export type IssueAgency = {
  order: number;
  id: number;
  name: string;
  logo: string | null;
};

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

// Shapes inferred from the repository's `findSummaryById`, so the mapping
// helpers stay in sync with the relations we actually load.
type SummaryWithRelations = NonNullable<
  Awaited<ReturnType<MeetingSummaryRepository['findSummaryById']>>
>;
type SummaryMeetingRequest = NonNullable<
  SummaryWithRelations['meetingRequest']
>;
type SummaryIssue = SummaryMeetingRequest['issues'][number];
type SummaryMeetingRequestAgency =
  SummaryMeetingRequest['governmentAgencies'][number];
type SummaryIssueResolve = SummaryWithRelations['issueResolves'][number];

@Injectable()
export class MeetingSummaryService {
  constructor(
    private readonly repository: MeetingSummaryRepository,
    private readonly systemNotifications: SystemNotificationsService,
    private readonly excelExport: ExcelExportService,
    private readonly uploads: UploadService,
  ) {}

  async create(
    dto: CreateMeetingSummaryDto,
    actorUserId: number,
    file?: Express.Multer.File,
  ) {
    const ministryStakeholderIds =
      await this.repository.findMinistryStakeholderIdsByUserId(actorUserId);
    const meeting = await this.repository.findMeetingForSummary(
      dto.meetingId,
      ministryStakeholderIds,
    );

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

    if (!meeting.meetingRequestId) {
      throw new BadRequestException(
        'This meeting is not linked to a meeting request.',
      );
    }

    if (meeting.status !== MeetingStatus.SCHEDULED) {
      throw new BadRequestException(
        'A meeting summary can be created only for a Scheduled meeting.',
      );
    }

    if (!meeting.meetingDate) {
      throw new BadRequestException(
        'A meeting date is required before creating a meeting summary.',
      );
    }

    if (!this.isMeetingDateBeforeToday(meeting.meetingDate)) {
      throw new BadRequestException(
        'A meeting summary can be created starting the day after the meeting date.',
      );
    }

    const existingSummary = await this.repository.findActiveSummaryByMeetingId(
      meeting.id,
    );

    if (existingSummary) {
      throw new ConflictException(DUPLICATE_MEETING_SUMMARY_MESSAGE);
    }

    let uploadedDocumentReference: string | undefined;
    let summaryWasCreated = false;

    try {
      uploadedDocumentReference = await this.saveUploadedPdf(file);
      const documentReference =
        uploadedDocumentReference ?? (dto.documentReference?.trim() || null);
      const isSubmitting = dto.status === 'SUBMITTED';

      if (isSubmitting && !documentReference) {
        throw new BadRequestException(
          'Upload the summary document before submitting to CDC.',
        );
      }

      // Create as a draft first when the caller submits immediately. The shared
      // submission workflow below then applies all status side effects.
      const meetingSummaryStatusId = await this.resolveStatusId(
        isSubmitting
          ? DEFAULT_STATUS_CODE
          : (dto.status ?? DEFAULT_STATUS_CODE),
      );

      const summary = await this.repository.createSummary({
        meetingRequestId: meeting.meetingRequestId,
        meetingId: meeting.id,
        documentReference,
        meetingSummaryStatusId,
        userId: actorUserId,
        ...this.getParticipantCreateData(dto),
      });
      summaryWasCreated = true;

      for (const issueResolve of dto.issueResolves ?? []) {
        await this.applyIssueResolve(summary.id, issueResolve, actorUserId);
      }

      if (isSubmitting) {
        const submittedStatusId = await this.resolveStatusId('SUBMITTED');

        await this.submitSummary(summary, submittedStatusId, documentReference);
        await this.notifyCdcGpsfSummarySubmitted(summary, actorUserId);
      }

      return {
        message: 'Meeting summary saved.',
        meetingSummary: await this.loadDetail(summary.id, actorUserId),
      };
    } catch (error) {
      if (uploadedDocumentReference && !summaryWasCreated) {
        await this.uploads.remove(uploadedDocumentReference);
      }

      if (this.isUniqueConstraintError(error)) {
        throw new ConflictException(DUPLICATE_MEETING_SUMMARY_MESSAGE);
      }

      throw error;
    }
  }

  private isMeetingDateBeforeToday(meetingDate: Date) {
    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 isUniqueConstraintError(error: unknown) {
    return (
      typeof error === 'object' &&
      error !== null &&
      'code' in error &&
      error.code === 'P2002'
    );
  }

  async findAll(query: ListMeetingSummariesDto = {}, currentUserId?: number) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 10;

    const scope = await this.buildVisibilityScope(currentUserId);

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

    return {
      items: result.data.map((summary) => this.mapSummaryToRow(summary)),
      meta: {
        page,
        limit,
        total: result.total,
        totalPages: Math.ceil(result.total / limit),
      },
    };
  }

  // GET /meeting-summaries/export — build an Excel file with the same rows
  // (and the same visibility rules) as the list endpoint, but without paging.
  async exportSummaries(
    currentUserId?: number,
  ): Promise<{ buffer: Buffer; filename: string }> {
    const scope = await this.buildVisibilityScope(currentUserId);
    const summaries = await this.repository.findAllSummariesForExport(scope);

    const rows = summaries.map((summary, index) => {
      const row = this.mapSummaryToRow(summary);

      return {
        no: index + 1,
        summaryTitle: row.summaryTitle,
        meetingDate: row.meetingDate
          ? new Date(row.meetingDate).toISOString().slice(0, 10)
          : '-',
        issueCount: row.issueCount,
        governmentAgency: row.governmentAgency ?? '-',
        pswg: row.pswg,
        status: row.status,
        meetingPswg: row.meetingPswg,
      };
    });

    const buffer = await this.excelExport.buildWorkbookBuffer({
      sheetName: 'Meeting Summaries',
      columns: [
        { header: 'No.', key: 'no', width: 8 },
        { header: 'Summary Title', key: 'summaryTitle', width: 45 },
        { header: 'Meeting Date', key: 'meetingDate', width: 16 },
        { header: '# of Issue', key: 'issueCount', width: 12 },
        { header: 'Government Agency', key: 'governmentAgency', width: 28 },
        { header: 'PSWG', key: 'pswg', width: 28 },
        { header: 'Status', key: 'status', width: 14 },
        { header: 'Meeting PSWG', key: 'meetingPswg', width: 35 },
      ],
      rows,
    });

    const today = new Date().toISOString().slice(0, 10);

    return {
      buffer,
      filename: `meeting-summaries-${today}.xlsx`,
    };
  }

  async findOne(id: number, currentUserId?: number) {
    const scope = await this.buildVisibilityScope(currentUserId);
    const summary = await this.repository.findSummaryById(id, scope);

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

    return await this.mapSummaryToDetail(summary);
  }

  // PATCH /meeting-summaries/:id/status — change a summary's status by code.
  // Private-sector (PSWG) callers are row-scoped to summaries shared with their
  // working group; ministry/admin callers can change any summary.
  async changeStatus(id: number, statusCode: string, actorUserId: number) {
    if (statusCode === 'SUBMITTED') {
      throw new BadRequestException(
        'Use the meeting summary update endpoint to submit to CDC.',
      );
    }

    if (statusCode === 'COMPLETED') {
      throw new BadRequestException(
        'Meeting summary completion is set automatically after submission.',
      );
    }

    const scope = await this.buildVisibilityScope(actorUserId);
    const summary = await this.repository.findSummaryById(id, scope);

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

    const meetingSummaryStatusId = await this.resolveStatusId(statusCode);
    await this.repository.updateSummary(id, { meetingSummaryStatusId });

    return {
      message: 'Meeting summary status updated.',
      meetingSummary: await this.loadDetail(id, actorUserId),
    };
  }

  // PATCH /meeting-summaries/:id/share — share the summary with its PSWG working
  // group so private-sector users can see it, and notify that working group.
  async share(id: number, actorUserId: number) {
    const existingSummary = await this.ensureSummaryExists(id, actorUserId);

    if (existingSummary.share) {
      throw new BadRequestException(
        'Meeting summary has already been shared with PSWG.',
      );
    }

    const summary = await this.repository.shareSummary(id);

    // The working group = the stakeholder the meeting-request creator belongs to.
    const workingGroupStakeholderId =
      summary.meetingRequest?.user?.stakeholders?.find(
        (link) => link.stakeholder?.id,
      )?.stakeholder?.id ?? null;

    if (workingGroupStakeholderId) {
      const recipients = await this.repository.findUsersByStakeholderIds([
        workingGroupStakeholderId,
      ]);

      await this.systemNotifications.createForMeetingSummaryShared({
        meetingSummaryId: summary.id,
        meetingRequestId: summary.meetingRequestId,
        summaryTitle:
          summary.meetingRequest?.title ??
          summary.meeting?.title ??
          'Meeting Summary',
        senderUserId: actorUserId,
        senderName: summary.user?.name ?? 'Ministry',
        receiverUserIds: recipients.map((link) => link.userId),
      });
    }

    return {
      message: 'Meeting summary shared with PSWG.',
      meetingSummary: await this.loadDetail(id, actorUserId),
    };
  }

  // PATCH /meeting-summaries/:id/unshare — reverse a share so the summary is no
  // longer visible to the PSWG working group.
  async unshare(id: number, actorUserId?: number) {
    await this.ensureSummaryExists(id, actorUserId);
    await this.repository.unshareSummary(id);

    return {
      message: 'Meeting summary unshared from PSWG.',
      meetingSummary: await this.loadDetail(id, actorUserId),
    };
  }

  // Build the visibility scope for the current caller. Ministry users see
  // summaries addressed to their Ministry. When the user is not assigned to a
  // Ministry, preserve the existing PSWG shared-summary scope. Users with no
  // stakeholder membership keep the existing unrestricted behavior.
  private async buildVisibilityScope(
    currentUserId?: number,
  ): Promise<Prisma.MeetingSummaryWhereInput | undefined> {
    if (!currentUserId) {
      return undefined;
    }

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

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

    const pswgStakeholderIds =
      await this.repository.findPrivateSectorStakeholderIdsByUserId(
        currentUserId,
      );

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

    return {
      share: true,
      meetingRequest: {
        user: {
          stakeholders: {
            some: { stakeholderId: { in: pswgStakeholderIds } },
          },
        },
      },
    };
  }

  // Government agencies for the issue-resolution agency dropdowns.
  async listAgencies() {
    const agencies = await this.repository.findGovernmentAgencies();

    return agencies.map((agency) => ({
      id: agency.id,
      name: agency.name,
      logo: agency.logo,
    }));
  }

  async update(
    id: number,
    dto: UpdateMeetingSummaryDto,
    actorUserId: number,
    file?: Express.Multer.File,
  ) {
    const summary = await this.ensureSummaryExists(id, actorUserId);

    if (dto.status === 'COMPLETED') {
      throw new BadRequestException(
        'Meeting summary completion is set automatically after submission.',
      );
    }

    let uploadedDocumentReference: string | undefined;
    let uploadedDocumentWasStored = false;

    try {
      uploadedDocumentReference = await this.saveUploadedPdf(file);
      const documentReference =
        uploadedDocumentReference ??
        (dto.documentReference?.trim() || undefined);

      const summaryUpdate: MeetingSummaryParticipantData & {
        documentReference?: string | null;
        meetingSummaryStatusId?: number;
      } = {};

      const participantUpdate = this.getParticipantUpdateData(dto);
      Object.assign(summaryUpdate, participantUpdate);

      if (documentReference !== undefined) {
        summaryUpdate.documentReference = documentReference;
      }

      if (dto.status !== undefined) {
        summaryUpdate.meetingSummaryStatusId = await this.resolveStatusId(
          dto.status,
        );
      }

      if (dto.status === 'SUBMITTED') {
        const nextDocumentReference =
          documentReference ?? summary.documentReference;

        if (!nextDocumentReference) {
          throw new BadRequestException(
            'Upload the summary document before submitting to CDC.',
          );
        }

        if (Object.keys(participantUpdate).length > 0) {
          await this.repository.updateSummary(id, participantUpdate);
        }

        await this.submitSummary(
          summary,
          summaryUpdate.meetingSummaryStatusId!,
          documentReference,
        );
        uploadedDocumentWasStored = Boolean(uploadedDocumentReference);
        await this.notifyCdcGpsfSummarySubmitted(summary, actorUserId);
      } else if (Object.keys(summaryUpdate).length > 0) {
        await this.repository.updateSummary(id, summaryUpdate);
        uploadedDocumentWasStored = Boolean(uploadedDocumentReference);
      }

      if (uploadedDocumentReference) {
        await this.uploads.remove(
          this.normalizeStoredDocumentPath(summary.documentReference),
        );
      }

      for (const issueResolve of dto.issueResolves ?? []) {
        await this.applyIssueResolve(id, issueResolve, actorUserId);
      }

      return {
        message: 'Meeting summary updated.',
        meetingSummary: await this.loadDetail(id, actorUserId),
      };
    } catch (error) {
      if (uploadedDocumentReference && !uploadedDocumentWasStored) {
        await this.uploads.remove(uploadedDocumentReference);
      }

      throw error;
    }
  }

  async upsertIssue(
    id: number,
    issueId: number,
    dto: UploadIssueResolveDto,
    actorUserId: number,
    file?: Express.Multer.File,
  ) {
    const summary = await this.ensureSummaryExists(id, actorUserId);

    if (
      dto.governmentAgencies !== undefined &&
      !summary.meetingRequest?.issues.some((issue) => issue.id === issueId)
    ) {
      throw new BadRequestException(
        'The issue does not belong to this meeting summary.',
      );
    }

    const previousDocumentReference = summary.issueResolves.find(
      (resolve) => resolve.issueId === issueId,
    )?.documentReference;
    let uploadedDocumentReference: string | undefined;
    let uploadedDocumentWasStored = false;

    try {
      uploadedDocumentReference = await this.saveUploadedPdf(file);

      await this.applyIssueResolve(
        id,
        { ...dto, issueId },
        actorUserId,
        uploadedDocumentReference,
      );
      uploadedDocumentWasStored = Boolean(uploadedDocumentReference);

      if (uploadedDocumentReference) {
        await this.uploads.remove(
          this.normalizeStoredDocumentPath(previousDocumentReference),
        );
      }

      return {
        message: 'Issue resolution saved.',
        meetingSummary: await this.loadDetail(id, actorUserId),
      };
    } catch (error) {
      if (uploadedDocumentReference && !uploadedDocumentWasStored) {
        await this.uploads.remove(uploadedDocumentReference);
      }

      throw error;
    }
  }

  async remove(id: number, actorUserId?: number) {
    await this.ensureSummaryExists(id, actorUserId);

    await this.repository.softDeleteSummary(id);

    return { message: 'Meeting summary deleted.' };
  }

  // --- internal helpers -------------------------------------------------

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

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

    return uploadedFile.url;
  }

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

    if (!value) {
      return null;
    }

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

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

  // Save one issue's resolution. Resolves the escalation name to its row id and
  // hands everything to the repository (which creates or updates the row).
  private async applyIssueResolve(
    meetingSummaryId: number,
    input: IssueResolveInputDto,
    actorUserId: number,
    documentReference?: string,
  ) {
    if (input.governmentAgencies !== undefined) {
      await this.ensureGovernmentAgenciesAreValid(input.governmentAgencies);
    }

    let escalateId: number | null | undefined;

    if (input.escalate !== undefined) {
      if (input.escalate === null) {
        escalateId = null;
      } else {
        const escalate = await this.repository.findEscalateByName(
          input.escalate,
        );

        if (!escalate) {
          throw new BadRequestException(
            `Escalation target "${input.escalate}" is not configured.`,
          );
        }

        escalateId = escalate.id;
      }
    }

    // The resolution status now points at the issue_statuses table. The DTO
    // normalizes labels to status codes (e.g. "In Progress" -> "IN_PROGRESS").
    let issueStatusId: number | null | undefined;

    if (input.status !== undefined) {
      const issueStatus = await this.repository.findIssueStatusByCode(
        input.status,
      );

      if (!issueStatus) {
        throw new BadRequestException(
          `Issue status "${input.status}" is not configured.`,
        );
      }

      issueStatusId = issueStatus.id;

      // Keep the underlying working-group issue's status in sync with the
      // resolution status chosen here.
      await this.repository.updateIssueStatus(input.issueId, issueStatus.id);
    }

    await this.repository.upsertIssueResolve({
      meetingSummaryId,
      issueId: input.issueId,
      userId: actorUserId,
      issueStatusId,
      escalateId,
      rgcDecision: input.rgcDecision,
      nextStep: input.nextStep,
      remark: input.remark,
      documentReference,
      agencyStakeholderIds: input.agencyStakeholderIds,
      governmentAgencies: input.governmentAgencies,
    });

    const escalationTarget = this.getIssueEscalationTarget(input.escalate);

    if (escalationTarget) {
      await this.notifyEscalationRecipients(
        meetingSummaryId,
        input.issueId,
        actorUserId,
        escalationTarget,
      );
    }
  }

  private async ensureGovernmentAgenciesAreValid(
    agencies: IssueGovernmentAgencyInput[],
  ) {
    const stakeholderIds = new Set<number>();
    const agencyOrders = new Set<number>();

    for (const agency of agencies) {
      if (stakeholderIds.has(agency.stakeholderId)) {
        throw new BadRequestException('Each agency can only be selected once');
      }

      if (agencyOrders.has(agency.agencyOrder)) {
        throw new BadRequestException(
          'Each agency order can only be used once',
        );
      }

      stakeholderIds.add(agency.stakeholderId);
      agencyOrders.add(agency.agencyOrder);
    }

    if (!agencyOrders.has(1)) {
      throw new BadRequestException('A primary government agency is required');
    }

    const foundAgencies = await this.repository.findGovernmentAgenciesByIds([
      ...stakeholderIds,
    ]);

    if (foundAgencies.length !== stakeholderIds.size) {
      throw new BadRequestException(
        'One or more government agencies were not found',
      );
    }
  }

  // Best-effort: notify every active user holding the escalation target role.
  // Never blocks saving the resolution — a notification failure is swallowed.
  private async notifyEscalationRecipients(
    meetingSummaryId: number,
    issueId: number,
    actorUserId: number,
    escalationTarget: IssueEscalationTarget,
  ) {
    try {
      const receiverUserIds = await this.repository.findActiveUserIdsByRoleName(
        escalationTarget.toLowerCase(),
      );

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

      const [issue, actor] = await Promise.all([
        this.repository.findIssueTitleById(issueId),
        this.repository.findUserNameById(actorUserId),
      ]);

      await this.systemNotifications.createForIssueEscalated({
        issueId,
        issueTitle: issue?.title ?? 'an issue',
        meetingSummaryId,
        escalateName: escalationTarget,
        senderUserId: actorUserId,
        senderName: actor?.name ?? 'Ministry',
        receiverUserIds,
      });
    } catch {
      // Swallow — the resolution has already been saved successfully.
    }
  }

  private async notifyCdcGpsfSummarySubmitted(
    summary: SummaryWithRelations,
    actorUserId: number,
  ) {
    const receiverUserIds =
      await this.repository.findActiveUserIdsByRoleName(CDC_GPSF_ROLE_NAME);

    await this.systemNotifications.createForCdcGpsfMeetingSummarySubmitted({
      meetingSummaryId: summary.id,
      meetingRequestId: summary.meetingRequestId,
      summaryTitle:
        summary.meetingRequest?.title ??
        summary.meeting?.title ??
        'Meeting Summary',
      senderUserId: actorUserId,
      senderName: summary.user?.name ?? 'Ministry',
      receiverUserIds,
    });
  }

  private async submitSummary(
    summary: SummaryWithRelations,
    submittedStatusId: number,
    documentReference: string | null | undefined,
  ) {
    try {
      await this.repository.submitSummaryAndCompleteMeetingRequest(
        summary.id,
        summary.meetingRequestId,
        summary.meetingId,
        submittedStatusId,
        documentReference,
      );
    } catch (error) {
      if (error instanceof MeetingRequestNotReadyToCompleteError) {
        throw new BadRequestException(error.message);
      }

      throw error;
    }
  }

  private getIssueEscalationTarget(
    value: string | null | undefined,
  ): IssueEscalationTarget | null {
    const normalizedValue = value?.trim().toUpperCase();

    return (
      ISSUE_ESCALATION_TARGETS.find((target) => target === normalizedValue) ??
      null
    );
  }

  private async loadDetail(id: number, currentUserId?: number) {
    const summary = await this.ensureSummaryExists(id, currentUserId);

    return await this.mapSummaryToDetail(summary);
  }

  // Text inputs are optional. Empty text becomes null so a user can clear a
  // saved participant value instead of leaving an empty string in the database.
  private toNullableText(value?: string): string | null {
    const trimmed = value?.trim();
    return trimmed || null;
  }

  private getParticipantCreateData(
    dto: CreateMeetingSummaryDto,
  ): Required<MeetingSummaryParticipantData> {
    return {
      pswg_reporter: this.toNullableText(dto.pswgReporter),
      pswg_reporter_position: this.toNullableText(dto.pswgReporterPosition),
      pswg_representative: this.toNullableText(dto.pswgRepresentative),
      pswg_representative_position: this.toNullableText(
        dto.pswgRepresentativePosition,
      ),
      ministry_reporter: this.toNullableText(dto.ministryReporter),
      ministry_reporter_position: this.toNullableText(
        dto.ministryReporterPosition,
      ),
      ministry_representative: this.toNullableText(dto.ministryRepresentative),
      ministry_representative_position: this.toNullableText(
        dto.ministryRepresentativePosition,
      ),
    };
  }

  private getParticipantUpdateData(
    dto: UpdateMeetingSummaryDto,
  ): MeetingSummaryParticipantData {
    const data: MeetingSummaryParticipantData = {};

    if (dto.pswgReporter !== undefined) {
      data.pswg_reporter = this.toNullableText(dto.pswgReporter);
    }
    if (dto.pswgReporterPosition !== undefined) {
      data.pswg_reporter_position = this.toNullableText(
        dto.pswgReporterPosition,
      );
    }
    if (dto.pswgRepresentative !== undefined) {
      data.pswg_representative = this.toNullableText(dto.pswgRepresentative);
    }
    if (dto.pswgRepresentativePosition !== undefined) {
      data.pswg_representative_position = this.toNullableText(
        dto.pswgRepresentativePosition,
      );
    }
    if (dto.ministryReporter !== undefined) {
      data.ministry_reporter = this.toNullableText(dto.ministryReporter);
    }
    if (dto.ministryReporterPosition !== undefined) {
      data.ministry_reporter_position = this.toNullableText(
        dto.ministryReporterPosition,
      );
    }
    if (dto.ministryRepresentative !== undefined) {
      data.ministry_representative = this.toNullableText(
        dto.ministryRepresentative,
      );
    }
    if (dto.ministryRepresentativePosition !== undefined) {
      data.ministry_representative_position = this.toNullableText(
        dto.ministryRepresentativePosition,
      );
    }

    return data;
  }

  private async ensureSummaryExists(id: number, currentUserId?: number) {
    const scope = await this.buildVisibilityScope(currentUserId);
    const summary = await this.repository.findSummaryById(id, scope);

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

    return summary;
  }

  // Resolve a status code (e.g. "DRAFT") to its lookup-table id, or fail with a
  // clear message when the code is not one of the seeded statuses.
  private async resolveStatusId(code: string): Promise<number> {
    const status = await this.repository.findStatusIdByCode(code);

    if (!status) {
      throw new BadRequestException(`Unknown meeting summary status: ${code}`);
    }

    return status.id;
  }

  // A single row for the summary list table. Most columns come from the linked
  // meeting and meeting request.
  private mapSummaryToRow(summary: SummaryWithRelations) {
    const meetingRequest = summary.meetingRequest;
    const primaryAgency = this.getPrimaryAgency(summary);

    return {
      id: summary.id,
      // The summary title is the linked meeting-request title.
      summaryTitle: meetingRequest?.title ?? summary.meeting?.title ?? '-',
      meetingDate: summary.meeting?.meetingDate ?? null,
      issueCount: meetingRequest?.issues.length ?? 0,
      meetingRequest: meetingRequest?.meetingRequestLetter ?? null,
      meetingSummary: summary.documentReference,
      governmentAgency: primaryAgency.name,
      governmentAgencyLogo: primaryAgency.logo,
      pswg: this.getWorkingGroupName(summary),
      status: summary.meetingSummaryStatus.code as MeetingSummaryStatus,
      meetingPswg: summary.meeting?.title ?? '-',
    };
  }

  // The full detail used by the view/edit screen. Each issue is merged with its
  // resolution so the editable fields are prefilled.
  private async mapSummaryToDetail(summary: SummaryWithRelations) {
    const meetingRequest = summary.meetingRequest;
    const primaryAgency = this.getPrimaryAgency(summary);
    // Describe every document on the screen (summary, meeting minutes and the
    // meeting-request letter) with its name and size.
    const [documentReference, meetingDocument, meetingRequestLetter] =
      await Promise.all([
        this.uploads.getMetadata(
          this.normalizeStoredDocumentPath(summary.documentReference),
        ),
        this.uploads.getMetadata(
          this.normalizeStoredDocumentPath(summary.meeting?.documentReference),
        ),
        this.uploads.getMetadata(
          this.normalizeStoredDocumentPath(
            meetingRequest?.meetingRequestLetter,
          ),
        ),
      ]);

    const resolvesByIssueId = new Map(
      summary.issueResolves.map((resolve) => [resolve.issueId, resolve]),
    );

    // Read name/size/mimeType off disk for every solution reference, the same
    // way the summary document is described.
    const resolveDocumentsByIssueId = new Map(
      await Promise.all(
        summary.issueResolves.map(
          async (resolve) =>
            [
              resolve.issueId,
              await this.uploads.getMetadata(
                this.normalizeStoredDocumentPath(resolve.documentReference),
              ),
            ] as const,
        ),
      ),
    );

    // The same for the document each issue was submitted with.
    const issueAttachmentsByIssueId = new Map(
      await Promise.all(
        (meetingRequest?.issues ?? []).map(
          async (issue) =>
            [
              issue.id,
              await this.uploads.getMetadata(
                this.normalizeStoredDocumentPath(issue.attachment),
              ),
            ] as const,
        ),
      ),
    );

    const issues = (meetingRequest?.issues ?? []).map((issue) =>
      this.mergeIssueWithResolve(
        issue,
        resolvesByIssueId.get(issue.id) ?? null,
        meetingRequest?.governmentAgencies ?? [],
        resolveDocumentsByIssueId.get(issue.id) ?? null,
        issueAttachmentsByIssueId.get(issue.id) ?? null,
      ),
    );

    return {
      id: summary.id,
      documentReference,
      status: summary.meetingSummaryStatus.code as MeetingSummaryStatus,
      share: summary.share,
      pswgReporter: summary.pswg_reporter,
      pswgReporterPosition: summary.pswg_reporter_position,
      pswgRepresentative: summary.pswg_representative,
      pswgRepresentativePosition: summary.pswg_representative_position,
      ministryReporter: summary.ministry_reporter,
      ministryReporterPosition: summary.ministry_reporter_position,
      ministryRepresentative: summary.ministry_representative,
      ministryRepresentativePosition: summary.ministry_representative_position,
      createdAt: summary.createdAt,
      updatedAt: summary.updatedAt,
      createdBy: summary.user
        ? { id: summary.user.id, name: summary.user.name }
        : null,
      meeting: summary.meeting
        ? { ...summary.meeting, documentReference: meetingDocument }
        : summary.meeting,
      meetingRequest: meetingRequest
        ? {
            id: meetingRequest.id,
            title: meetingRequest.title,
            status: meetingRequest.status,
            meetingRequestLetter,
            submittedBy: meetingRequest.user?.name ?? null,
            governmentAgency: primaryAgency.name,
            governmentAgencyLogo: primaryAgency.logo,
            pswg: this.getWorkingGroupName(summary),
          }
        : null,
      issues,
    };
  }

  private mergeIssueWithResolve(
    issue: SummaryIssue,
    resolve: SummaryIssueResolve | null,
    meetingRequestAgencies: SummaryMeetingRequestAgency[],
    resolveDocument: UploadedFileMetadata | null = null,
    issueAttachment: UploadedFileMetadata | null = null,
  ) {
    const primary =
      issue.governmentAgencies.find((agency) => agency.agencyOrder === 1)
        ?.stakeholder ??
      issue.governmentAgencies[0]?.stakeholder ??
      null;

    const secondAgency = this.getIssueAgencySlot(
      issue,
      resolve,
      meetingRequestAgencies,
      2,
    );
    const thirdAgency = this.getIssueAgencySlot(
      issue,
      resolve,
      meetingRequestAgencies,
      3,
    );
    const fourthAgency = this.getIssueAgencySlot(
      issue,
      resolve,
      meetingRequestAgencies,
      4,
    );
    const fifthAgency = this.getIssueAgencySlot(
      issue,
      resolve,
      meetingRequestAgencies,
      5,
    );

    const agencies = [
      this.toIssueAgency(1, {
        id: primary?.id ?? null,
        name: primary?.name ?? '-',
        logo: primary?.logo ?? null,
      }),
      this.toIssueAgency(2, secondAgency),
      this.toIssueAgency(3, thirdAgency),
      this.toIssueAgency(4, fourthAgency),
      this.toIssueAgency(5, fifthAgency),
    ].filter((agency): agency is IssueAgency => agency !== null);

    return {
      id: issue.id,
      issue: issue.title,
      category: issue.category?.name ?? '-',
      issueDescription: issue.description,
      recommendation: issue.recommendation,
      // The document submitted with the issue itself (issues.attachment).
      issueReference: issueAttachment,
      // The master issue status is shared with Progress Reports. Keep the
      // summary resolve status as a fallback for older records where the
      // original issue status is not loaded yet.
      resolveId: resolve?.id ?? null,
      status:
        issue.issueStatus?.code ??
        resolve?.issueStatus?.code ??
        'NEW_SUBMISSION',
      escalation: resolve?.escalate?.name ?? null,
      rgcDecision: resolve?.rgcDecision ?? null,
      nextStep: resolve?.nextStep ?? null,
      remark: resolve?.remark ?? null,
      // The solution reference, described with its name and size so the UI can
      // show a proper file card. Null until a document is uploaded.
      referenceDocument: resolveDocument,
      // Agencies are returned as one ordered list. Empty agency slots are
      // omitted from the list.
      agencies,
    };
  }

  private toIssueAgency(
    order: number,
    slot: IssueAgencySlot,
  ): IssueAgency | null {
    if (slot.id === null || !this.isUploadedAgencyName(slot.name)) {
      return null;
    }

    return {
      order,
      id: slot.id,
      name: slot.name,
      logo: slot.logo,
    };
  }

  private isUploadedAgencyName(name: string): boolean {
    return name !== 'Not Uploaded' && name !== '-';
  }

  private getIssueAgencySlot(
    issue: SummaryIssue,
    resolve: SummaryIssueResolve | null,
    meetingRequestAgencies: SummaryMeetingRequestAgency[],
    agencyOrder: number,
  ): { id: number | null; name: string; logo: string | null } {
    const resolveIndex = agencyOrder - 2;
    const resolveAgency =
      resolveIndex >= 0
        ? (resolve?.issueResolveAgencies?.[resolveIndex] ?? null)
        : null;
    const resolveStakeholder = resolveAgency?.stakeholder ?? null;

    const issueAgency = issue.governmentAgencies.find(
      (agency) => agency.agencyOrder === agencyOrder,
    );
    const fallbackAgency = meetingRequestAgencies[agencyOrder - 1] ?? null;

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

    const id =
      resolveAgency?.stakeholderId ??
      issueAgency?.stakeholderId ??
      fallbackAgency?.stakeholderId ??
      stakeholder?.id ??
      null;

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

  private getPrimaryAgency(summary: SummaryWithRelations): {
    name: string;
    logo: string | null;
  } {
    const stakeholder =
      summary.meetingRequest?.governmentAgencies[0]?.stakeholder ?? null;

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

  // Pick a working-group / private-sector stakeholder name for the PSWG column.
  // Prefer the stakeholder the requesting user belongs to (their working group
  // / ministry). Fall back to a private-sector agency on the request, then to
  // the requester's personal name.
  private getWorkingGroupName(summary: SummaryWithRelations): string {
    const requesterStakeholder =
      summary.meetingRequest?.user?.stakeholders?.find(
        (link) => link.stakeholder?.name,
      )?.stakeholder;

    if (requesterStakeholder?.name) {
      return requesterStakeholder.name;
    }

    const agencies = summary.meetingRequest?.governmentAgencies ?? [];

    const workingGroup = agencies
      .map((agency) => agency.stakeholder)
      .find((stakeholder) => {
        if (!stakeholder) {
          return false;
        }

        const typeName = stakeholder.stakeholderType?.name?.toLowerCase() ?? '';

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

    return workingGroup?.name ?? summary.meetingRequest?.user?.name ?? '-';
  }
}
