import { Op } from 'sequelize';
import db from '@/db/models';
import { withTransaction } from '@/db/with-transaction';
import ValidationError from '@/shared/errors/validation';
import ForbiddenError from '@/shared/errors/forbidden';
import {
  assertAuthenticatedTenantUser,
  getRoleName,
  getCampusId,
  getClassId,
  hasFeaturePermission,
  getOrganizationId,
  requireUserId,
} from '@/services/shared/access';
import { ROLE_NAMES } from '@/shared/constants/roles';
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
import {
  conversationKey,
  dedupeContacts,
  displayName,
  studentWhere,
} from '@/services/direct_messages.helpers';
import type { CurrentUser } from '@/db/api/types';
import type { Users } from '@/db/models/users';
import type {
  DirectMessageContact,
  DirectMessageInput,
} from '@/services/direct_messages.types';

/** Eager-loads users with their role name, filtered to the given role names. */
function withRole(names: readonly string[]) {
  return {
    model: db.roles,
    as: 'app_role',
    where: { name: { [Op.in]: names } },
    required: true,
    attributes: ['name'],
  };
}

const USER_ATTRS = ['id', 'firstName', 'lastName', 'email', 'classId', 'campusId'];

function assertCanUseDirectMessages(currentUser?: CurrentUser): void {
  if (!hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.READ_PARENT_COMM)) {
    throw new ForbiddenError();
  }
}

async function campusIdsByClassId(classIds: readonly string[]): Promise<Map<string, string>> {
  const uniqueClassIds = [...new Set(classIds)];
  if (!uniqueClassIds.length) return new Map();
  const classes = await db.classes.findAll({
    where: { id: { [Op.in]: uniqueClassIds } },
    attributes: ['id', 'campusId'],
  });
  return new Map(
    classes
      .filter((row) => row.campusId)
      .map((row) => [row.id, row.campusId as string]),
  );
}

async function campusIdForStudents(students: readonly Users[]): Promise<Map<string, string | null>> {
  const classIds = students.map((student) => student.classId).filter(Boolean) as string[];
  const classCampusIds = await campusIdsByClassId(classIds);
  return new Map(students.map((student) => [
    student.id,
    student.campusId ?? (student.classId ? classCampusIds.get(student.classId) ?? null : null),
  ]));
}

/**
 * Direct 1:1 conversations between staff (teacher / office_manager) and
 * guardians, discovered through a shared student. Access is
 * membership-based, so each conversation is isolated to its two participants.
 */
export default class DirectMessagesService {
  /** The students the current user is connected to (own class/campus or wards). */
  private static async myStudents(currentUser: CurrentUser): Promise<Users[]> {
    const role = getRoleName(currentUser);
    const me = requireUserId(currentUser);

    if (role === ROLE_NAMES.GUARDIAN) {
      const links = await db.guardian_students.findAll({
        where: { guardianId: me },
        attributes: ['studentId'],
      });
      const studentIds = [...new Set(links.map((l) => l.studentId))];
      if (!studentIds.length) return [];
      return db.users.findAll({
        where: { id: { [Op.in]: studentIds } },
        attributes: USER_ATTRS,
      });
    }

    // Staff: students of their class (teacher) or campus (office_manager).
    if (role === ROLE_NAMES.OFFICE_MANAGER) {
      const campusId = getCampusId(currentUser);
      if (!campusId) return [];
      const classes = await db.classes.findAll({
        where: { campusId },
        attributes: ['id'],
      });
      const classIds = classes.map((row) => row.id);
      return db.users.findAll({
        where: {
          [Op.or]: [
            { campusId },
            ...(classIds.length ? [{ classId: { [Op.in]: classIds } }] : []),
          ],
        },
        include: [withRole([ROLE_NAMES.STUDENT])],
        attributes: USER_ATTRS,
      });
    }

    const classId = getClassId(currentUser);
    if (!classId) return [];
    return db.users.findAll({
      where: { classId },
      include: [withRole([ROLE_NAMES.STUDENT])],
      attributes: USER_ATTRS,
    });
  }

  /** People the current user may start/continue a conversation with. */
  static async contacts(currentUser?: CurrentUser): Promise<{ rows: DirectMessageContact[] }> {
    assertAuthenticatedTenantUser(currentUser);
    assertCanUseDirectMessages(currentUser);
    const user = currentUser as CurrentUser;
    const role = getRoleName(user);
    const students = await this.myStudents(user);
    if (!students.length) return { rows: [] };

    if (role === ROLE_NAMES.GUARDIAN) {
      const classIds = [...new Set(students.map((s) => s.classId).filter(Boolean))] as string[];
      const campusByStudent = await campusIdForStudents(students);
      const campusIds = [...new Set([...campusByStudent.values()].filter(Boolean))] as string[];

      const teachers = classIds.length
        ? await db.users.findAll({
            where: { classId: { [Op.in]: classIds } },
            include: [withRole([ROLE_NAMES.TEACHER])],
            attributes: USER_ATTRS,
          })
        : [];
      const offices = campusIds.length
        ? await db.users.findAll({
            where: { campusId: { [Op.in]: campusIds } },
            include: [withRole([ROLE_NAMES.OFFICE_MANAGER])],
            attributes: USER_ATTRS,
          })
        : [];

      const rows: DirectMessageContact[] = [];
      for (const staff of teachers) {
        for (const student of students.filter((s) => s.classId && s.classId === staff.classId)) {
          rows.push({
            conversationKey: conversationKey(staff.id, student.id),
            userId: staff.id,
            name: displayName(staff),
            role: staff.app_role?.name ?? null,
            studentId: student.id,
            studentName: displayName(student),
          });
        }
      }
      for (const staff of offices) {
        for (const student of students.filter((s) => campusByStudent.get(s.id) === staff.campusId)) {
          rows.push({
            conversationKey: conversationKey(staff.id, student.id),
            userId: staff.id,
            name: displayName(staff),
            role: staff.app_role?.name ?? null,
            studentId: student.id,
            studentName: displayName(student),
          });
        }
      }
      return { rows: dedupeContacts(rows) };
    }

    // Staff → the guardians of their students.
    const studentIds = students.map((s) => s.id);
    const links = await db.guardian_students.findAll({
      where: { studentId: { [Op.in]: studentIds } },
      attributes: ['guardianId', 'studentId'],
    });
    if (!links.length) return { rows: [] };
    const guardianIds = [...new Set(links.map((l) => l.guardianId))];
    const guardians = await db.users.findAll({
      where: { id: { [Op.in]: guardianIds } },
      attributes: USER_ATTRS,
    });
    const studentById = new Map(students.map((s) => [s.id, s]));
    const guardianById = new Map(guardians.map((guardian) => [guardian.id, guardian]));
    const rows: DirectMessageContact[] = links.flatMap((link) => {
      const guardian = guardianById.get(link.guardianId);
      const student = studentById.get(link.studentId);
      if (!guardian || !student) return [];
      return [{
        conversationKey: conversationKey(guardian.id, student.id),
        userId: guardian.id,
        name: displayName(guardian),
        role: ROLE_NAMES.GUARDIAN,
        studentId: student.id,
        studentName: displayName(student),
      }];
    });
    return { rows: dedupeContacts(rows) };
  }

  /** Whether the current user may message `otherUserId` for a specific student. */
  private static async assertCanMessage(
    currentUser: CurrentUser,
    otherUserId: string,
    studentIdRaw?: unknown,
  ): Promise<DirectMessageContact> {
    const requestedStudentId = typeof studentIdRaw === 'string' && studentIdRaw
      ? studentIdRaw
      : null;
    const { rows } = await this.contacts(currentUser);
    const matches = rows.filter((row) => row.userId === otherUserId);
    const contact = requestedStudentId
      ? matches.find((row) => row.studentId === requestedStudentId)
      : matches.length === 1
        ? matches[0]
        : undefined;
    if (!contact) {
      throw new ForbiddenError();
    }
    return contact;
  }

  /** The current user's conversations (one row per counterpart + student). */
  static async conversations(currentUser?: CurrentUser) {
    assertAuthenticatedTenantUser(currentUser);
    assertCanUseDirectMessages(currentUser);
    const me = requireUserId(currentUser);

    const messages = await db.direct_messages.findAll({
      where: { [Op.or]: [{ senderId: me }, { recipientId: me }] },
      order: [['createdAt', 'DESC']],
    });

    const byConversation = new Map<
      string,
      { otherUserId: string; studentId: string | null; lastBody: string; lastAt: Date; unread: number }
    >();
    for (const message of messages) {
      const other = message.senderId === me ? message.recipientId : message.senderId;
      const key = conversationKey(other, message.studentId ?? null);
      const existing = byConversation.get(key);
      const isUnread = message.recipientId === me && message.readAt === null;
      if (!existing) {
        byConversation.set(key, {
          otherUserId: other,
          studentId: message.studentId ?? null,
          lastBody: message.body,
          lastAt: message.createdAt,
          unread: isUnread ? 1 : 0,
        });
      } else if (isUnread) {
        existing.unread += 1;
      }
    }

    const conversations = [...byConversation.values()];
    const counterpartIds = [...new Set(conversations.map((row) => row.otherUserId))];
    const studentIds = [...new Set(conversations.map((row) => row.studentId).filter(Boolean))] as string[];
    if (!counterpartIds.length) return { rows: [] };
    const users = await db.users.findAll({
      where: { id: { [Op.in]: counterpartIds } },
      attributes: USER_ATTRS,
    });
    const students = studentIds.length
      ? await db.users.findAll({
          where: { id: { [Op.in]: studentIds } },
          attributes: USER_ATTRS,
        })
      : [];
    const userById = new Map(users.map((u) => [u.id, u]));
    const studentById = new Map(students.map((u) => [u.id, u]));

    const rows = conversations.map((info) => {
      const other = userById.get(info.otherUserId) ?? null;
      const student = info.studentId ? studentById.get(info.studentId) ?? null : null;
      return {
        conversationKey: conversationKey(info.otherUserId, info.studentId),
        userId: info.otherUserId,
        name: displayName(other),
        studentId: info.studentId,
        studentName: student ? displayName(student) : null,
        lastMessage: info.lastBody,
        lastAt: info.lastAt,
        unread: info.unread,
      };
    });
    rows.sort((a, b) => (b.lastAt?.getTime() ?? 0) - (a.lastAt?.getTime() ?? 0));
    return { rows };
  }

  /** Messages between the current user and `otherUserId`; marks incoming read. */
  static async thread(
    otherUserIdRaw: unknown,
    studentIdRaw?: unknown,
    currentUser?: CurrentUser,
  ) {
    assertAuthenticatedTenantUser(currentUser);
    assertCanUseDirectMessages(currentUser);
    const user = currentUser as CurrentUser;
    const me = requireUserId(user);
    const otherUserId = typeof otherUserIdRaw === 'string' ? otherUserIdRaw : '';
    if (!otherUserId) throw new ValidationError();
    const contact = await this.assertCanMessage(user, otherUserId, studentIdRaw);
    const scopedStudentId = contact.studentId;

    const messages = await db.direct_messages.findAll({
      where: {
        [Op.or]: [
          { senderId: me, recipientId: otherUserId },
          { senderId: otherUserId, recipientId: me },
        ],
        ...studentWhere(scopedStudentId),
      },
      order: [['createdAt', 'ASC']],
    });

    // Mark messages addressed to me as read.
    await db.direct_messages.update(
      { readAt: new Date() },
      {
        where: {
          senderId: otherUserId,
          recipientId: me,
          readAt: null,
          ...studentWhere(scopedStudentId),
        },
      },
    );

    const other = await db.users.findByPk(otherUserId, { attributes: USER_ATTRS });

    return {
      other: other ? {
        userId: other.id,
        name: displayName(other),
        studentId: contact.studentId,
        studentName: contact.studentName,
      } : null,
      rows: messages.map((message) => ({
        id: message.id,
        body: message.body,
        senderId: message.senderId,
        mine: message.senderId === me,
        createdAt: message.createdAt,
      })),
    };
  }

  /** Sends a message to `recipientId` (must be a valid contact). */
  static async send(
    data: DirectMessageInput,
    currentUser?: CurrentUser,
  ) {
    assertAuthenticatedTenantUser(currentUser);
    assertCanUseDirectMessages(currentUser);
    const user = currentUser as CurrentUser;
    const me = requireUserId(user);

    const recipientId = typeof data?.recipientId === 'string' ? data.recipientId : '';
    const body = typeof data?.body === 'string' ? data.body.trim() : '';
    if (!recipientId || !body) {
      throw new ValidationError();
    }

    const contact = await this.assertCanMessage(user, recipientId, data?.studentId);
    const studentId =
      typeof data?.studentId === 'string' && data.studentId
        ? data.studentId
        : contact.studentId;

    return withTransaction(async (transaction) => {
      const message = await db.direct_messages.create(
        {
          senderId: me,
          recipientId,
          studentId: studentId ?? null,
          body,
          organizationId: getOrganizationId(user),
          createdById: me,
          updatedById: me,
        },
        { transaction },
      );
      return {
        id: message.id,
        body: message.body,
        senderId: message.senderId,
        mine: true,
        createdAt: message.createdAt,
      };
    });
  }
}
