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

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

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

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

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

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

// How long the background ministry email may run before we give up on it.
const MINISTRY_EMAIL_TIMEOUT_MS = 20_000;

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

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

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

  constructor(
    private readonly prisma: PrismaService,
    private readonly resendMailMeetingRequestsService: ResendMailMeetingRequestsService,
    private readonly systemNotificationsService: SystemNotificationsService,
  ) {}

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

    const { stakeholderIds, issueIds, meetingRequestLetter } = dto;

    const uploadedLetterPath = file
      ? `/uploads/meeting-requests/${file.filename}`
      : meetingRequestLetter;

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

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

          userId: actorUserId,

          status: MEETING_REQUEST_STATUS.DRAFT,
          meetingRequestLetter: uploadedLetterPath,

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    return this.addMeetingCreationAccess(data);
  }

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

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

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

    return data;
  }

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

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

    const { stakeholderIds, issueIds, meetingRequestLetter } = dto;

    const uploadedLetterPath = file
      ? `/uploads/meeting-requests/${file.filename}`
      : meetingRequestLetter;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    const updatedRequest = await this.submitRequestAndLinkedIssues(id);

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

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

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

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

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

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

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

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

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

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

      return;
    }

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

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

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

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

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

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

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

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

    const ministryStakeholderIds =
      await this.getUserMinistryStakeholderIds(actorUserId);

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

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

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

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

    return this.addMeetingCreationAccess(updatedRequest);
  }

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

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

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

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

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

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

    const result = await tx.issues.updateMany({
      where: {
        id: {
          in: uniqueIssueIds,
        },
        deletedAt: null,
        OR: [{ meetingRequestId: null }, { meetingRequestId }],
      },
      data: {
        meetingRequestId,
      },
    });

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

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

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

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

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

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

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

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

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

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

      return updatedRequest;
    });
  }

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

    if (!user) {
      return null;
    }

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

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

    return {
      id: user.id,
      name: user.name ?? null,
      email,
    };
  }

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

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

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

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

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

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

    const validUsers: EmailUser[] = [];

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

      if (!hasMinistryRole) {
        continue;
      }

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

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

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

    return validUsers;
  }

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

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

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

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

      issues: {
        where: {
          deletedAt: null,
        },
        include: {
          issueStatus: true,
          category: true,
          governmentAgencies: {
            orderBy: {
              agencyOrder: 'asc',
            },
            include: {
              stakeholder: true,
            },
          },
        },
      },

      meetings: {
        where: {
          deletedAt: null,
        },
        select: {
          id: true,
          status: true,
        },
      },
    } as const;
  }

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

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