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 { 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,
  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.';

type IssueEscalationTarget = (typeof ISSUE_ESCALATION_TARGETS)[number];

// 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,
  ) {}

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

    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);
    }

    const documentReference =
      file?.path ?? (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),
    );

    let summary;

    try {
      summary = await this.repository.createSummary({
        meetingRequestId: meeting.meetingRequestId,
        meetingId: meeting.id,
        documentReference,
        meetingSummaryStatusId,
        userId: actorUserId,
        ...this.getParticipantCreateData(dto),
      });
    } catch (error) {
      if (this.isUniqueConstraintError(error)) {
        throw new ConflictException(DUPLICATE_MEETING_SUMMARY_MESSAGE);
      }

      throw error;
    }

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

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

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

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

  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.buildPswgVisibilityScope(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.buildPswgVisibilityScope(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.buildPswgVisibilityScope(currentUserId);
    const summary = await this.repository.findSummaryById(id, scope);

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

    return 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.buildPswgVisibilityScope(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),
    };
  }

  // 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);

    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),
    };
  }

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

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

  // Build the visibility scope for the current caller. Private-sector (PSWG)
  // users only see summaries that have been shared with them AND belong to their
  // own working group. Ministry/admin callers get no scope (see everything).
  private async buildPswgVisibilityScope(
    currentUserId?: number,
  ): Promise<Prisma.MeetingSummaryWhereInput | undefined> {
    if (!currentUserId) {
      return undefined;
    }

    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);

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

    const documentReference =
      file?.path ?? (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.submitSummaryAndNotify(
        summary,
        summaryUpdate.meetingSummaryStatusId!,
        documentReference,
        actorUserId,
      );
    } else if (Object.keys(summaryUpdate).length > 0) {
      await this.repository.updateSummary(id, summaryUpdate);
    }

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

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

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

    await this.applyIssueResolve(
      id,
      { ...dto, issueId },
      actorUserId,
      file?.path,
    );

    return {
      message: 'Issue resolution saved.',
      meetingSummary: await this.loadDetail(id),
    };
  }

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

    await this.repository.softDeleteSummary(id);

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

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

  // 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,
  ) {
    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,
    });

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

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

  // 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 submitSummaryAndNotify(
    summary: SummaryWithRelations,
    submittedStatusId: number,
    documentReference: string | null | undefined,
    actorUserId: number,
  ) {
    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;
    }

    await this.notifyCdcGpsfSummarySubmitted(summary, actorUserId);
  }

  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) {
    const summary = await this.ensureSummaryExists(id);

    return 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) {
    const summary = await this.repository.findSummaryById(id);

    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 mapSummaryToDetail(summary: SummaryWithRelations) {
    const meetingRequest = summary.meetingRequest;
    const primaryAgency = this.getPrimaryAgency(summary);

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

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

    return {
      id: summary.id,
      documentReference: summary.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,
      meetingRequest: meetingRequest
        ? {
            id: meetingRequest.id,
            title: meetingRequest.title,
            status: meetingRequest.status,
            meetingRequestLetter: meetingRequest.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[],
  ) {
    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,
    );

    return {
      id: issue.id,
      issue: issue.title,
      category: issue.category?.name ?? '-',
      issueDescription: issue.description,
      recommendation: issue.recommendation,
      issueReference: issue.attachment,
      issueStatus: issue.issueStatus?.name ?? null,
      primaryAgency: primary?.name ?? '-',
      primaryAgencyLogo: primary?.logo ?? null,
      secondAgency: secondAgency.name,
      secondAgencyLogo: secondAgency.logo,
      thirdAgency: thirdAgency.name,
      thirdAgencyLogo: thirdAgency.logo,
      fourthAgency: fourthAgency.name,
      fourthAgencyLogo: fourthAgency.logo,
      fifthAgency: fifthAgency.name,
      fifthAgencyLogo: fifthAgency.logo,
      // Resolution data. Null/"NEW_SUBMISSION" means the issue is unresolved.
      resolveId: resolve?.id ?? null,
      status: resolve?.issueStatus?.code ?? 'NEW_SUBMISSION',
      escalation: resolve?.escalate?.name ?? null,
      rgcDecision: resolve?.rgcDecision ?? null,
      nextStep: resolve?.nextStep ?? null,
      remark: resolve?.remark ?? null,
      referenceDocument: resolve?.documentReference ?? null,
      agencies: this.buildIssueAgenciesList([
        secondAgency,
        thirdAgency,
        fourthAgency,
        fifthAgency,
      ]),
    };
  }

  private buildIssueAgenciesList(
    slots: Array<{ id: number | null; name: string; logo: string | null }>,
  ): Array<{ id: number; name: string; logo: string | null }> {
    return slots
      .filter(
        (
          agency,
        ): agency is {
          id: number;
          name: string;
          logo: string | null;
        } => agency.id !== null && this.isUploadedAgencyName(agency.name),
      )
      .map((agency) => ({
        id: agency.id,
        name: agency.name,
        logo: agency.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 ?? '-';
  }
}
