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

import {
  Prisma,
  ProgressReportDeadlineSlot,
  ProgressReportDeadlineStatus,
  ProgressReportMeetingSlot,
  ProgressReportMeetingStatus,
  ProgressReportSemester,
  ProgressReportStatus,
} from '@/generated/prisma/client';
import { SystemNotificationsService } from '@/modules/system-notifications/system-notifications.service';
import { UploadService } from '@/modules/upload/upload.service';
import { MinistryProgressReportsService } from '@/modules/ministry-progress-reports/ministry-progress-reports.service';

import { CreateProgressReportDeadlineDto } from './dto/create-progress-report-deadline.dto';
import { CreateProgressReportMeetingDto } from './dto/create-progress-report-meeting.dto';
import { CreateProgressReportDto } from './dto/create-progress-report.dto';
import { QueryProgressReportsDto } from './dto/query-progress-reports.dto';
import { UpdateProgressReportDeadlineDto } from './dto/update-progress-report-deadline.dto';
import { UpdateProgressReportMeetingDto } from './dto/update-progress-report-meeting.dto';
import { UpdateProgressReportDto } from './dto/update-progress-report.dto';
import {
  mapDeadline,
  mapDeadlines,
  mapListMeetings,
} from './progress-report.mapper';
import {
  ProgressReportsRepository,
  type ProgressReportMeetingRecord,
  type ProgressReportWithRelations,
} from './progress-reports.repository';

export type ProgressReportUploadedFiles = {
  attachment?: Express.Multer.File;
  draftSemesterReport?: Express.Multer.File;
  finalSemesterReport?: Express.Multer.File;
};

// URLs of files this request just stored, keyed by form field name.
type SavedProgressReportFiles = {
  attachment?: string;
  draftSemesterReport?: string;
  finalSemesterReport?: string;
};

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

  constructor(
    private readonly repository: ProgressReportsRepository,
    private readonly uploads: UploadService,
    private readonly systemNotifications: SystemNotificationsService,
    private readonly ministryProgressReports: MinistryProgressReportsService,
  ) {}

  // A new report is always a DRAFT. It is stored together with its FIRST
  // deadline in one transaction, so a report can never exist without one.
  async create(
    dto: CreateProgressReportDto,
    userId: number,
    files?: ProgressReportUploadedFiles,
  ) {
    const savedFiles: SavedProgressReportFiles = {};
    let report: ProgressReportWithRelations;

    try {
      await this.ensureSemesterIsAvailable(dto.year, dto.semester);
      await this.saveUploadedFiles(files, savedFiles);
      const data = this.buildCreateData(dto, userId, savedFiles);

      report = await this.repository.createWithFirstDeadline(
        data,
        this.toDeadlineDate(dto.firstDeadline),
      );
    } catch (error) {
      await this.removeSavedFiles(savedFiles);

      if (this.isUniqueConstraintError(error)) {
        throw this.createDuplicateSemesterError(dto.year, dto.semester);
      }

      throw error;
    }

    return {
      message: 'Progress report created successfully',
      ...(await this.serializeProgressReport(report)),
    };
  }

  // Sending is the only way to move a report from DRAFT to SENT, so the
  // frontend never writes the status field itself.
  async send(id: number, userId: number) {
    const report = await this.repository.findActiveById(id);

    if (!report) {
      throw new NotFoundException('Progress report not found');
    }

    if (report.status === ProgressReportStatus.SENT) {
      throw new ConflictException('Progress report has already been sent');
    }

    this.ensureReportIsReadyToSend(report);

    const ministryAssignments =
      await this.ministryProgressReports.buildAssignmentsForSentReport();

    const sentReport = await this.repository.update(id, {
      status: ProgressReportStatus.SENT,
      deadlines: {
        updateMany: {
          where: {
            slot: ProgressReportDeadlineSlot.FIRST,
            deletedAt: null,
          },
          data: { status: ProgressReportDeadlineStatus.SENT },
        },
      },
      ...(ministryAssignments.length > 0
        ? {
            ministries: {
              createMany: { data: ministryAssignments, skipDuplicates: true },
            },
          }
        : {}),
    });

    this.logger.log(`User ${userId} sent progress report ${id}`);

    // The report is already saved at this point, so a notification failure
    // must never fail the request.
    await this.notifyMinistriesForStep(sentReport, 'INITIAL_REPORT', userId);

    return {
      message: 'Progress report sent successfully',
      ...(await this.serializeProgressReport(sentReport)),
    };
  }

  // Build the filters and return one page of all active reports.
  async findAll(query: QueryProgressReportsDto, userId?: number) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 10;
    const ministryId = userId
      ? await this.ministryProgressReports.getListMinistryId(userId)
      : undefined;
    const where = this.buildListWhere(query, ministryId);

    const [items, total] = await this.repository.findManyAndCount({
      where,
      skip: (page - 1) * limit,
      take: limit,
    });

    return {
      data: await Promise.all(
        items.map((item) => this.serializeProgressReport(item, ministryId)),
      ),
      meta: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    };
  }

  // Route permissions decide who can read reports.
  async findOne(id: number, userId?: number) {
    const report = await this.repository.findActiveById(id);

    if (!report) {
      throw new NotFoundException(
        `Progress report with ID ${id} was not found`,
      );
    }

    const ministryId = userId
      ? await this.ministryProgressReports.getListMinistryId(userId)
      : undefined;

    if (
      ministryId !== undefined &&
      (report.status !== ProgressReportStatus.SENT ||
        !report.ministries.some(
          (assignment) => assignment.ministryId === ministryId,
        ))
    ) {
      throw new NotFoundException(
        `Progress report with ID ${id} was not found`,
      );
    }

    return await this.serializeProgressReport(report, ministryId);
  }

  // Check that the report exists, then update only the provided fields.
  async update(
    id: number,
    dto: UpdateProgressReportDto,
    files?: ProgressReportUploadedFiles,
  ) {
    let targetYear: number | undefined;
    let targetSemester: ProgressReportSemester | undefined;
    const savedFiles: SavedProgressReportFiles = {};
    let report: ProgressReportWithRelations;

    try {
      const existingReport = await this.ensureActiveProgressReport(id);
      const isDraftSemesterUpload = this.isDraftSemesterUploadOnly(dto, files);

      // Sent report information stays locked. The draft semester PDF is the
      // only document that can still be uploaded after the report is sent.
      if (
        existingReport.status !== ProgressReportStatus.DRAFT &&
        !isDraftSemesterUpload
      ) {
        throw new ConflictException(
          'Only a DRAFT progress report can be edited',
        );
      }

      if (!isDraftSemesterUpload) {
        targetYear = dto.year ?? existingReport.year;
        targetSemester = dto.semester ?? existingReport.semester;

        await this.ensureSemesterIsAvailable(targetYear, targetSemester, id);
      }

      await this.saveUploadedFiles(files, savedFiles);
      const data = this.buildUpdateData(dto, savedFiles);

      report = await this.repository.update(id, data);
    } catch (error) {
      await this.removeSavedFiles(savedFiles);

      if (
        this.isUniqueConstraintError(error) &&
        targetYear !== undefined &&
        targetSemester !== undefined
      ) {
        throw this.createDuplicateSemesterError(targetYear, targetSemester);
      }

      throw error;
    }

    return {
      message: 'Progress report updated successfully',
      ...(await this.serializeProgressReport(report)),
    };
  }

  // The Set Deadline modal fills the later slots. FIRST is created together
  // with the report, so it is rejected here.
  async createDeadline(
    progressReportId: number,
    dto: CreateProgressReportDeadlineDto,
  ) {
    if (dto.slot === ProgressReportDeadlineSlot.FIRST) {
      throw new BadRequestException(
        'The first deadline must be created with the progress report',
      );
    }

    const report = await this.ensureActiveProgressReport(progressReportId);
    this.ensureCanCreateDeadline(report, dto.slot);

    const existing = await this.repository.findActiveDeadlineBySlot(
      progressReportId,
      dto.slot,
    );

    if (existing) {
      throw this.createDuplicateDeadlineSlotError(dto.slot);
    }

    const date = this.toDeadlineDate(dto.deadline);

    try {
      // A soft-deleted deadline still holds the slot in the unique
      // constraint, so reuse that row instead of leaving the slot unusable.
      const deleted = await this.repository.findAnyDeadlineBySlot(
        progressReportId,
        dto.slot,
      );

      const deadline = deleted
        ? await this.repository.restoreAndUpdateDeadline(deleted.id, date)
        : await this.repository.createDeadline({
            slot: dto.slot,
            deadline: date,
            progressReport: { connect: { id: progressReportId } },
          });

      return {
        message: 'Progress report deadline created successfully',
        ...mapDeadline(deadline),
      };
    } catch (error) {
      // The unique constraint is the last guard when two requests race.
      if (this.isUniqueConstraintError(error)) {
        throw this.createDuplicateDeadlineSlotError(dto.slot);
      }

      throw error;
    }
  }

  // Returns a fixed shape so the frontend never has to guess which deadline
  // is which.
  async findDeadlines(progressReportId: number) {
    await this.ensureActiveProgressReport(progressReportId);

    const deadlines =
      await this.repository.findActiveDeadlinesByProgressReportId(
        progressReportId,
      );

    return mapDeadlines(deadlines);
  }

  async findDeadline(deadlineId: number) {
    return mapDeadline(await this.ensureActiveDeadline(deadlineId));
  }

  async updateDeadline(
    deadlineId: number,
    dto: UpdateProgressReportDeadlineDto,
  ) {
    const current = await this.ensureDraftDeadline(deadlineId);
    await this.ensureActiveProgressReport(current.progressReportId);
    const date = this.toDeadlineDate(dto.deadline);

    const deadline = await this.repository.updateDeadline(deadlineId, {
      deadline: date,
    });

    return {
      message: 'Progress report deadline updated successfully',
      ...mapDeadline(deadline),
    };
  }

  async removeDeadline(deadlineId: number) {
    const deadline = await this.ensureActiveDeadline(deadlineId);

    // FIRST is created with the report and every report must keep one.
    if (deadline.slot === ProgressReportDeadlineSlot.FIRST) {
      throw new ConflictException(
        'The first deadline cannot be deleted because every progress report needs one',
      );
    }

    if (deadline.status !== ProgressReportDeadlineStatus.DRAFT) {
      throw new ConflictException('Only a DRAFT deadline can be deleted.');
    }

    await this.repository.softDeleteDeadline(deadlineId);

    return { message: 'Progress report deadline deleted successfully' };
  }

  // Check that the report exists, then mark it as deleted.
  async remove(id: number) {
    await this.findOne(id);

    return this.repository.softDelete(id);
  }

  async createMeeting(
    progressReportId: number,
    dto: CreateProgressReportMeetingDto,
    userId: number,
    document?: Express.Multer.File,
  ) {
    if (!document) {
      throw new BadRequestException('Meeting PDF document is required');
    }

    const startTime = this.toTime(dto.startTime);
    const endTime = this.toTime(dto.endTime);
    const meetingDate = this.toDate(dto.meetingDate);

    this.ensureEndAfterStart(startTime, endTime);

    let documentReference: string | undefined;

    try {
      const report = await this.ensureActiveProgressReport(progressReportId);
      this.ensureCanCreateMeeting(report, dto.slot);

      const activeMeeting =
        await this.repository.findMeetingByProgressReportIdAndSlot(
          progressReportId,
          dto.slot,
        );

      if (activeMeeting) {
        throw this.createDuplicateMeetingSlotError(dto.slot);
      }

      this.ensureMeetingDateOrder(report, dto.slot, meetingDate);

      const uploadedDocument = await this.uploads.save(
        document,
        'progress-report-meetings',
        { allowedMimeTypes: ['application/pdf'] },
      );
      documentReference = uploadedDocument.url;

      const meetingData = {
        title: dto.title,
        description: dto.description,
        meetingDate,
        startTime,
        endTime,
        location: dto.location,
        documentReference,
        status: ProgressReportMeetingStatus.DRAFT,
      };

      // A soft-deleted meeting still holds the slot in the unique constraint,
      // so reuse that row instead of leaving the slot unusable forever.
      const deletedMeeting =
        await this.repository.findAnyMeetingByProgressReportIdAndSlot(
          progressReportId,
          dto.slot,
        );

      const meeting = deletedMeeting
        ? await this.repository.updateMeeting(deletedMeeting.id, {
            ...meetingData,
            deletedAt: null,
            user: { connect: { id: userId } },
          })
        : await this.repository.createMeeting({
            ...meetingData,
            slot: dto.slot,
            progressReport: { connect: { id: progressReportId } },
            user: { connect: { id: userId } },
          });

      return {
        message: 'Progress report meeting created successfully.',
        ...(await this.serializeMeeting(meeting)),
      };
    } catch (error) {
      if (documentReference) {
        await this.uploads.remove(documentReference);
      }

      // The unique constraint is the last guard when two requests race.
      if (this.isUniqueConstraintError(error)) {
        throw this.createDuplicateMeetingSlotError(dto.slot);
      }

      throw error;
    }
  }

  // Returns a fixed shape so the frontend never has to guess which meeting
  // is the first one.
  async findMeetings(progressReportId: number) {
    await this.ensureActiveProgressReport(progressReportId);

    const meetings =
      await this.repository.findMeetingsByProgressReportId(progressReportId);

    const [firstMeeting, secondMeeting] = await Promise.all([
      this.serializeMeetingOrNull(
        meetings.find(
          (meeting) => meeting.slot === ProgressReportMeetingSlot.FIRST,
        ),
      ),
      this.serializeMeetingOrNull(
        meetings.find(
          (meeting) => meeting.slot === ProgressReportMeetingSlot.SECOND,
        ),
      ),
    ]);

    return { firstMeeting, secondMeeting };
  }

  async findMeeting(meetingId: number) {
    const meeting = await this.ensureActiveMeeting(meetingId);

    return await this.serializeMeeting(meeting);
  }

  async updateMeeting(
    meetingId: number,
    dto: UpdateProgressReportMeetingDto,
    document?: Express.Multer.File,
  ) {
    const hasUpdatedField = Object.values(dto).some(
      (value) => value !== undefined,
    );

    if (!hasUpdatedField && !document) {
      throw new BadRequestException('No meeting fields provided to update');
    }

    const currentMeeting = await this.ensureDraftMeeting(
      meetingId,
      'Only a DRAFT meeting can be edited.',
    );
    const startTime = dto.startTime
      ? this.toTime(dto.startTime)
      : currentMeeting.startTime;
    const endTime = dto.endTime
      ? this.toTime(dto.endTime)
      : currentMeeting.endTime;
    const meetingDate = dto.meetingDate
      ? this.toDate(dto.meetingDate)
      : currentMeeting.meetingDate;

    this.ensureEndAfterStart(startTime, endTime);

    const report = await this.ensureActiveProgressReport(
      currentMeeting.progressReportId,
    );
    this.ensureMeetingDateOrder(report, currentMeeting.slot, meetingDate);

    let newDocumentReference: string | undefined;

    try {
      if (document) {
        const uploadedDocument = await this.uploads.save(
          document,
          'progress-report-meetings',
          { allowedMimeTypes: ['application/pdf'] },
        );
        newDocumentReference = uploadedDocument.url;
      }

      const meeting = await this.repository.updateMeeting(meetingId, {
        ...(dto.title !== undefined ? { title: dto.title } : {}),
        ...(dto.description !== undefined
          ? { description: dto.description }
          : {}),
        ...(dto.meetingDate !== undefined ? { meetingDate } : {}),
        ...(dto.startTime !== undefined ? { startTime } : {}),
        ...(dto.endTime !== undefined ? { endTime } : {}),
        ...(dto.location !== undefined ? { location: dto.location } : {}),
        ...(newDocumentReference
          ? { documentReference: newDocumentReference }
          : {}),
      });

      if (
        newDocumentReference &&
        currentMeeting.documentReference !== newDocumentReference
      ) {
        // remove() never throws, so a cleanup problem cannot fail the update.
        await this.uploads.remove(currentMeeting.documentReference);
      }

      return {
        message: 'Progress report meeting updated successfully.',
        ...(await this.serializeMeeting(meeting)),
      };
    } catch (error) {
      if (newDocumentReference) {
        await this.uploads.remove(newDocumentReference);
      }

      throw error;
    }
  }

  // Sending is the only way to move a meeting from DRAFT to SENT, so the
  // frontend never writes the status field itself.
  async sendMeeting(meetingId: number, userId: number) {
    const currentMeeting = await this.ensureActiveMeeting(meetingId);

    if (currentMeeting.status === ProgressReportMeetingStatus.SENT) {
      throw new ConflictException('This meeting has already been sent.');
    }

    this.ensureMeetingIsReadyToSend(currentMeeting);

    const report = await this.ensureActiveProgressReport(
      currentMeeting.progressReportId,
    );
    this.ensureCanCreateMeeting(report, currentMeeting.slot);
    this.ensureMeetingDateOrder(
      report,
      currentMeeting.slot,
      currentMeeting.meetingDate,
    );

    this.logger.log(`User ${userId} sent progress report meeting ${meetingId}`);

    const meeting = await this.repository.updateMeetingStatus(
      meetingId,
      ProgressReportMeetingStatus.SENT,
    );

    await this.notifyMinistriesForStep(
      report,
      currentMeeting.slot === ProgressReportMeetingSlot.FIRST
        ? 'FIRST_MEETING'
        : 'SECOND_MEETING',
      userId,
    );

    return {
      message: 'Progress report meeting sent successfully.',
      ...(await this.serializeMeeting(meeting)),
    };
  }

  async removeMeeting(meetingId: number) {
    await this.ensureDraftMeeting(
      meetingId,
      'Only a DRAFT meeting can be deleted.',
    );
    await this.repository.softDeleteMeeting(meetingId);

    return {
      message: 'Progress report meeting deleted successfully.',
    };
  }

  async sendDeadline(deadlineId: number, userId: number) {
    const deadline = await this.ensureActiveDeadline(deadlineId);

    if (deadline.slot === ProgressReportDeadlineSlot.FIRST) {
      throw new ConflictException(
        'The first deadline is sent together with the progress report.',
      );
    }

    if (deadline.status === ProgressReportDeadlineStatus.SENT) {
      throw new ConflictException('This deadline has already been sent.');
    }

    const report = await this.ensureActiveProgressReport(
      deadline.progressReportId,
    );
    this.ensureCanCreateDeadline(report, deadline.slot);

    const sentDeadline = await this.repository.updateDeadline(deadlineId, {
      status: ProgressReportDeadlineStatus.SENT,
    });

    await this.notifyMinistriesForStep(
      report,
      deadline.slot === ProgressReportDeadlineSlot.SECOND
        ? 'SECOND_DEADLINE'
        : 'FINAL_DEADLINE',
      userId,
    );

    return {
      message: 'Progress report deadline sent successfully',
      ...mapDeadline(sentDeadline),
    };
  }

  // Each report step creates its own notification for the Ministries assigned
  // when the report was first sent. Notification failures never roll back a
  // workflow step that was already saved.
  private async notifyMinistriesForStep(
    report: ProgressReportWithRelations,
    step:
      | 'INITIAL_REPORT'
      | 'FIRST_MEETING'
      | 'SECOND_DEADLINE'
      | 'SECOND_MEETING'
      | 'FINAL_DEADLINE',
    senderUserId: number,
  ) {
    try {
      const stakeholderIds = Array.from(
        new Set(report.ministries.map((assignment) => assignment.ministryId)),
      );

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

      const notificationInput = {
        progressReportId: report.id,
        progressReportTitle: report.title,
        senderUserId,
        receiverStakeholderIds: stakeholderIds,
      };

      if (step === 'INITIAL_REPORT') {
        await this.systemNotifications.createForProgressReportSent(
          notificationInput,
        );
      } else {
        await this.systemNotifications.createForProgressReportWorkflowStep({
          ...notificationInput,
          step,
        });
      }
    } catch (error) {
      this.logger.error(
        `Failed to notify ministries for progress report ${report.id} step ${step}`,
        error instanceof Error ? error.stack : String(error),
      );
    }
  }

  // The deadline is added by createWithFirstDeadline, so it is left out here.
  private buildCreateData(
    dto: CreateProgressReportDto,
    userId: number,
    savedFiles: SavedProgressReportFiles,
  ): Omit<Prisma.ProgressReportCreateInput, 'deadlines'> {
    const attachment = savedFiles.attachment || dto.attachment?.trim();

    if (!attachment) {
      throw new BadRequestException('Attachment PDF file is required');
    }

    return {
      title: dto.title,
      description: this.toDescriptionValue(dto.description),
      year: dto.year,
      semester: dto.semester,
      // A new report is always a draft. Only the send endpoint changes this.
      status: ProgressReportStatus.DRAFT,
      attachment,
      draftSemesterReport:
        savedFiles.draftSemesterReport || dto.draftSemesterReport || null,
      finalSemesterReport:
        savedFiles.finalSemesterReport || dto.finalSemesterReport || null,
      user: {
        connect: { id: userId },
      },
    };
  }

  // The description column stores JSON, so plain text and rich-text objects
  // are both valid. DbNull stores a real SQL NULL for an empty value.
  private toDescriptionValue(
    description: string | Record<string, unknown> | undefined,
  ): Prisma.InputJsonValue | typeof Prisma.DbNull {
    if (description === undefined) {
      return Prisma.DbNull;
    }

    if (typeof description === 'string') {
      return description.trim() === '' ? Prisma.DbNull : description;
    }

    return description as Prisma.InputJsonValue;
  }

  // Store a date-only value at UTC midnight so timezone conversion cannot
  // move the deadline to the previous or next calendar day.
  private toDeadlineDate(deadline: string): Date {
    return new Date(`${deadline}T00:00:00.000Z`);
  }

  private toDateOnly(value: Date): string {
    return value.toISOString().slice(0, 10);
  }

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

  // Times are stored on 1970-01-01, so "09:00" and "09:00:00" both work.
  private toTime(value: string): Date {
    const withSeconds = value.length === 5 ? `${value}:00` : value;

    return new Date(`1970-01-01T${withSeconds}.000Z`);
  }

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

  private createDuplicateMeetingSlotError(slot: ProgressReportMeetingSlot) {
    return new ConflictException(
      `The ${slot} meeting already exists for this progress report`,
    );
  }

  private createDuplicateDeadlineSlotError(slot: ProgressReportDeadlineSlot) {
    return new ConflictException(
      `The ${slot} deadline already exists for this progress report`,
    );
  }

  private async ensureActiveDeadline(deadlineId: number) {
    const deadline = await this.repository.findActiveDeadlineById(deadlineId);

    if (!deadline) {
      throw new NotFoundException('Progress report deadline not found');
    }

    return deadline;
  }

  private async ensureDraftDeadline(deadlineId: number) {
    const deadline = await this.ensureActiveDeadline(deadlineId);

    if (deadline.status !== ProgressReportDeadlineStatus.DRAFT) {
      throw new ConflictException('Only a DRAFT deadline can be edited.');
    }

    return deadline;
  }

  private ensureCanCreateMeeting(
    report: ProgressReportWithRelations,
    slot: ProgressReportMeetingSlot,
  ) {
    if (report.status !== ProgressReportStatus.SENT) {
      throw new ConflictException(
        'Send the progress report before creating the first meeting.',
      );
    }

    if (slot === ProgressReportMeetingSlot.FIRST) {
      const firstDeadlineSent = report.deadlines.some(
        (deadline) =>
          deadline.slot === ProgressReportDeadlineSlot.FIRST &&
          deadline.status === ProgressReportDeadlineStatus.SENT,
      );

      if (!firstDeadlineSent) {
        throw new ConflictException(
          'Send the first deadline before creating the first meeting.',
        );
      }

      return;
    }

    const secondDeadlineSent = report.deadlines.some(
      (deadline) =>
        deadline.slot === ProgressReportDeadlineSlot.SECOND &&
        deadline.status === ProgressReportDeadlineStatus.SENT,
    );

    if (!secondDeadlineSent) {
      throw new ConflictException(
        'Send the second deadline before creating the second meeting.',
      );
    }
  }

  private ensureCanCreateDeadline(
    report: ProgressReportWithRelations,
    slot: ProgressReportDeadlineSlot,
  ) {
    if (report.status !== ProgressReportStatus.SENT) {
      throw new ConflictException(
        'Send the progress report before setting another deadline.',
      );
    }

    if (slot === ProgressReportDeadlineSlot.SECOND) {
      const firstMeetingSent = report.meetings.some(
        (meeting) =>
          meeting.slot === ProgressReportMeetingSlot.FIRST &&
          meeting.status === ProgressReportMeetingStatus.SENT,
      );

      if (!firstMeetingSent) {
        throw new ConflictException(
          'Send the first meeting before setting the second deadline.',
        );
      }

      return;
    }

    if (slot === ProgressReportDeadlineSlot.FINAL) {
      const secondMeetingSent = report.meetings.some(
        (meeting) =>
          meeting.slot === ProgressReportMeetingSlot.SECOND &&
          meeting.status === ProgressReportMeetingStatus.SENT,
      );

      if (!secondMeetingSent) {
        throw new ConflictException(
          'Send the second meeting before setting the final deadline.',
        );
      }
    }
  }

  private ensureMeetingDateOrder(
    report: ProgressReportWithRelations,
    slot: ProgressReportMeetingSlot,
    meetingDate: Date,
  ) {
    const previousStages =
      slot === ProgressReportMeetingSlot.FIRST
        ? []
        : [
            this.getMeetingStage(
              report,
              ProgressReportMeetingSlot.FIRST,
              'first meeting',
            ),
            this.getDeadlineStage(
              report,
              ProgressReportDeadlineSlot.SECOND,
              'second deadline',
            ),
          ];

    this.ensureDateAfterPreviousStages(
      slot === ProgressReportMeetingSlot.FIRST
        ? 'first meeting'
        : 'second meeting',
      meetingDate,
      previousStages,
    );
    this.ensureDateWithinFirstDeadline(
      report,
      slot === ProgressReportMeetingSlot.FIRST
        ? 'first meeting'
        : 'second meeting',
      meetingDate,
    );
  }

  // The FIRST deadline is the overall reporting-period end date. Meetings
  // and later deadlines happen inside that range, so it is an upper bound.
  private ensureDateWithinFirstDeadline(
    report: ProgressReportWithRelations,
    currentLabel: string,
    currentDate: Date,
  ) {
    const firstDeadline = report.deadlines.find(
      (deadline) => deadline.slot === ProgressReportDeadlineSlot.FIRST,
    );

    if (firstDeadline && currentDate > firstDeadline.deadline) {
      throw new BadRequestException(
        `The ${currentLabel} must be on or before the first deadline (${this.toDateOnly(firstDeadline.deadline)}).`,
      );
    }
  }

  private getDeadlineStage(
    report: ProgressReportWithRelations,
    slot: ProgressReportDeadlineSlot,
    label: string,
  ) {
    const deadline = report.deadlines.find((item) => item.slot === slot);

    return deadline ? { label, date: deadline.deadline } : null;
  }

  private getMeetingStage(
    report: ProgressReportWithRelations,
    slot: ProgressReportMeetingSlot,
    label: string,
  ) {
    const meeting = report.meetings.find((item) => item.slot === slot);

    return meeting ? { label, date: meeting.meetingDate } : null;
  }

  private ensureDateAfterPreviousStages(
    currentLabel: string,
    currentDate: Date,
    stages: Array<{ label: string; date: Date } | null>,
  ) {
    const latestPreviousStage = stages
      .filter((stage): stage is { label: string; date: Date } => stage !== null)
      .sort((left, right) => right.date.getTime() - left.date.getTime())[0];

    if (latestPreviousStage && currentDate <= latestPreviousStage.date) {
      throw new BadRequestException(
        `The ${currentLabel} must be after the ${latestPreviousStage.label} (${this.toDateOnly(latestPreviousStage.date)}).`,
      );
    }
  }

  // Every field is required by the database, but a stored value can still be
  // empty, so check them again before the report goes out.
  private ensureReportIsReadyToSend(report: ProgressReportWithRelations) {
    const hasFirstDeadline = report.deadlines.some(
      (deadline) => deadline.slot === ProgressReportDeadlineSlot.FIRST,
    );

    if (
      !this.hasText(report.title) ||
      !this.hasText(report.attachment) ||
      !this.hasDescription(report.description) ||
      !hasFirstDeadline
    ) {
      throw new BadRequestException(
        'Complete the progress report before sending',
      );
    }
  }

  // Every field is required by the database, but a stored value can still be
  // an empty string, so check them again before the meeting goes out.
  private ensureMeetingIsReadyToSend(meeting: ProgressReportMeetingRecord) {
    const missingFields: string[] = [];

    if (!this.hasText(meeting.title)) {
      missingFields.push('title');
    }
    if (!this.hasDescription(meeting.description)) {
      missingFields.push('description');
    }
    if (!this.hasText(meeting.location)) {
      missingFields.push('location');
    }
    if (!this.hasText(meeting.documentReference)) {
      missingFields.push('documentReference');
    }

    if (missingFields.length > 0) {
      throw new BadRequestException(
        `Complete the meeting before sending it. Missing: ${missingFields.join(', ')}.`,
      );
    }
  }

  private hasText(value: string | null) {
    return typeof value === 'string' && value.trim() !== '';
  }

  // The description column stores JSON, so it can hold plain text or a
  // rich-text object.
  private hasDescription(value: Prisma.JsonValue) {
    if (value === null) {
      return false;
    }

    return typeof value === 'string' ? value.trim() !== '' : true;
  }

  private async serializeMeeting(meeting: ProgressReportMeetingRecord) {
    return {
      id: meeting.id,
      slot: meeting.slot,
      progressReportId: meeting.progressReportId,
      title: meeting.title,
      description: meeting.description,
      meetingDate: meeting.meetingDate.toISOString().slice(0, 10),
      startTime: meeting.startTime.toISOString().slice(11, 19),
      endTime: meeting.endTime.toISOString().slice(11, 19),
      location: meeting.location,
      documentReference: await this.uploads.getMetadata(
        meeting.documentReference,
      ),
      status: meeting.status,
    };
  }

  private async serializeMeetingOrNull(meeting?: ProgressReportMeetingRecord) {
    return meeting ? await this.serializeMeeting(meeting) : null;
  }

  // Builds the public report shape. Internal columns (userId, deletedAt) and
  // raw relation rows are left out on purpose.
  private async serializeProgressReport(
    report: ProgressReportWithRelations,
    ministryId?: number,
  ) {
    const currentAssignment =
      ministryId !== undefined
        ? report.ministries.find(
            (assignment) => assignment.ministryId === ministryId,
          )
        : undefined;
    const [
      attachment,
      draftSemesterReport,
      finalSemesterReport,
      ministryAttachment,
    ] = await Promise.all([
      this.uploads.getMetadata(report.attachment),
      this.uploads.getMetadata(report.draftSemesterReport),
      this.uploads.getMetadata(report.finalSemesterReport),
      this.uploads.getMetadata(currentAssignment?.attachment),
    ]);
    const visibleDeadlines =
      ministryId === undefined
        ? report.deadlines
        : report.deadlines.filter(
            (deadline) => deadline.status === ProgressReportDeadlineStatus.SENT,
          );
    const visibleMeetings =
      ministryId === undefined
        ? report.meetings
        : report.meetings.filter(
            (meeting) => meeting.status === ProgressReportMeetingStatus.SENT,
          );

    return {
      id: report.id,
      title: report.title,
      description: report.description,
      year: report.year,
      semester: report.semester,
      status: report.status,
      attachment,
      deadlines: mapDeadlines(visibleDeadlines),
      meetings: mapListMeetings(visibleMeetings),
      draftSemesterReport,
      finalSemesterReport,
      createdAt: report.createdAt,
      updatedAt: report.updatedAt,
      ...(ministryId !== undefined
        ? {
            ministryAssignment: currentAssignment
              ? {
                  ...currentAssignment,
                  attachment: ministryAttachment,
                }
              : null,
          }
        : {}),
    };
  }

  private async ensureActiveProgressReport(progressReportId: number) {
    const report = await this.repository.findActiveById(progressReportId);

    if (!report) {
      throw new NotFoundException(
        `Progress report with ID ${progressReportId} was not found`,
      );
    }

    return report;
  }

  private async ensureActiveMeeting(meetingId: number) {
    const meeting = await this.repository.findMeetingById(meetingId);

    if (!meeting) {
      throw new NotFoundException(
        `Progress report meeting with ID ${meetingId} was not found`,
      );
    }

    return meeting;
  }

  // A meeting that was already sent is locked, so it can no longer be
  // edited or deleted.
  private async ensureDraftMeeting(meetingId: number, message: string) {
    const meeting = await this.ensureActiveMeeting(meetingId);

    if (meeting.status !== ProgressReportMeetingStatus.DRAFT) {
      throw new ConflictException(message);
    }

    return meeting;
  }

  private buildUpdateData(
    dto: UpdateProgressReportDto,
    savedFiles: SavedProgressReportFiles,
  ): Prisma.ProgressReportUpdateInput {
    const attachment = savedFiles.attachment || dto.attachment?.trim();
    const draftSemesterReport =
      savedFiles.draftSemesterReport || dto.draftSemesterReport?.trim();
    const finalSemesterReport =
      savedFiles.finalSemesterReport || dto.finalSemesterReport?.trim();
    const shouldUpdateDraftSemesterReport =
      savedFiles.draftSemesterReport !== undefined ||
      dto.draftSemesterReport !== undefined;
    const shouldUpdateFinalSemesterReport =
      savedFiles.finalSemesterReport !== undefined ||
      dto.finalSemesterReport !== undefined;

    // Each spread adds a field only when the caller provided that field.
    // This keeps omitted fields unchanged in the database. "status" and the
    // deadlines are missing on purpose: they have their own endpoints.
    return {
      ...(dto.title !== undefined ? { title: dto.title } : {}),
      ...(dto.description !== undefined
        ? { description: this.toDescriptionValue(dto.description) }
        : {}),
      ...(dto.year !== undefined ? { year: dto.year } : {}),
      ...(dto.semester !== undefined ? { semester: dto.semester } : {}),
      ...(attachment ? { attachment } : {}),
      ...(shouldUpdateDraftSemesterReport
        ? { draftSemesterReport: draftSemesterReport || null }
        : {}),
      ...(shouldUpdateFinalSemesterReport
        ? { finalSemesterReport: finalSemesterReport || null }
        : {}),
    };
  }

  // A sent report accepts only one special update: replacing its draft
  // semester PDF. Mixing this upload with any report field or another file
  // remains blocked by the normal sent-report lock.
  private isDraftSemesterUploadOnly(
    dto: UpdateProgressReportDto,
    files?: ProgressReportUploadedFiles,
  ) {
    const hasReportField = Object.values(dto).some(
      (value) => value !== undefined,
    );
    const hasDraftSemesterFile = Boolean(files?.draftSemesterReport);
    const hasOtherFile = Boolean(
      files?.attachment || files?.finalSemesterReport,
    );

    return hasDraftSemesterFile && !hasReportField && !hasOtherFile;
  }

  // Store every uploaded file through the upload module and collect the URLs.
  // Filling the object as we go means the catch block can clean up files
  // that were already saved when a later step fails.
  private async saveUploadedFiles(
    files: ProgressReportUploadedFiles | undefined,
    saved: SavedProgressReportFiles,
  ) {
    const pdfOnly = { allowedMimeTypes: ['application/pdf'] };

    if (files?.attachment) {
      saved.attachment = (
        await this.uploads.save(files.attachment, 'progress-reports', pdfOnly)
      ).url;
    }
    if (files?.draftSemesterReport) {
      saved.draftSemesterReport = (
        await this.uploads.save(
          files.draftSemesterReport,
          'progress-reports',
          pdfOnly,
        )
      ).url;
    }
    if (files?.finalSemesterReport) {
      saved.finalSemesterReport = (
        await this.uploads.save(
          files.finalSemesterReport,
          'progress-reports',
          pdfOnly,
        )
      ).url;
    }
  }

  private async ensureSemesterIsAvailable(
    year: number,
    semester: ProgressReportSemester,
    excludeId?: number,
  ) {
    const existing = await this.repository.findActiveByYearAndSemester(
      year,
      semester,
      excludeId,
    );

    if (existing) {
      throw this.createDuplicateSemesterError(year, semester);
    }
  }

  private createDuplicateSemesterError(
    year: number,
    semester: ProgressReportSemester,
  ) {
    return new ConflictException(
      `A progress report for ${semester} in ${year} already exists.`,
    );
  }

  private isUniqueConstraintError(error: unknown) {
    return (
      typeof error === 'object' &&
      error !== null &&
      'code' in error &&
      error.code === 'P2002'
    );
  }

  // Remove only files from the rejected request, never existing report files.
  private async removeSavedFiles(saved: SavedProgressReportFiles) {
    const urls = [
      saved.attachment,
      saved.draftSemesterReport,
      saved.finalSemesterReport,
    ].filter((url): url is string => Boolean(url));

    await Promise.all(urls.map((url) => this.uploads.remove(url)));
  }

  private buildListWhere(
    query: QueryProgressReportsDto,
    ministryId?: number,
  ): Prisma.ProgressReportWhereInput {
    const search = query.search?.trim();

    // These base filters apply to every list request.
    return {
      deletedAt: null,
      ...(search
        ? {
            title: {
              contains: search,
              mode: 'insensitive',
            },
          }
        : {}),
      ...(query.year !== undefined ? { year: query.year } : {}),
      ...(query.semester !== undefined ? { semester: query.semester } : {}),
      ...(ministryId !== undefined
        ? {
            status: ProgressReportStatus.SENT,
            ministries: {
              some: { ministryId, deletedAt: null },
            },
          }
        : query.status !== undefined
          ? { status: query.status }
          : {}),
    };
  }
}
