import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { createReadStream, existsSync } from 'fs';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
import {
  CatalogLabelService,
  normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
import { normalizeTeeth } from '../treatments/treatment.utils';
import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto';
import {
  isLabCaseOverdue,
} from '../../common/lab-case-due-date';
import { normalizeTaskTeeth } from './lab-case-task.util';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import { UserNotificationService } from '../notifications/user-notification.service';
import { LabCaseAccessService } from './lab-case-access.service';

const labCaseListInclude = {
  treatment: {
    include: {
      organization: { select: { id: true, name: true } },
      patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
      appointment: { select: { startAt: true } },
    },
  },
  details: {
    include: {
      detail: {
        select: {
          id: true,
          treatmentType: true,
          teeth: true,
          comment: true,
        },
      },
    },
  },
  sends: {
    orderBy: [{ sentAt: 'asc' as const }],
    include: { organization: { select: { id: true, name: true } } },
  },
  tasks: {
    orderBy: [
      { treatmentDetailId: 'asc' as const },
      { prosthesisTypeCode: 'asc' as const },
      { stepOrder: 'asc' as const },
    ],
    include: {
      lastStatusChangedBy: { select: { id: true, name: true } },
      assignee: { select: { id: true, name: true } },
      statusEvents: {
        orderBy: { changedAt: 'asc' as const },
        include: { changedBy: { select: { id: true, name: true } } },
      },
    },
  },
  toothProsthesis: true,
  attachments: {
    include: {
      attachment: {
        select: {
          id: true,
          fileName: true,
          mimeType: true,
          sizeBytes: true,
          createdAt: true,
        },
      },
    },
  },
} satisfies Prisma.LabCaseInclude;

type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
  include: {
    lastStatusChangedBy: { select: { id: true; name: true } };
    assignee: { select: { id: true; name: true } };
    statusEvents: {
      include: { changedBy: { select: { id: true; name: true } } };
    };
  };
}>;

@Injectable()
export class CasesService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly prosthesisCatalog: ProsthesisCatalogService,
    private readonly catalogLabels: CatalogLabelService,
    private readonly labCaseActivity: LabCaseActivityService,
    private readonly userNotifications: UserNotificationService,
    private readonly labCaseAccess: LabCaseAccessService,
  ) {}

  getOrganizationIdFromUser(user: { organizationId?: string }) {
    if (!user?.organizationId) {
      throw new BadRequestException('Organization is not selected');
    }
    return user.organizationId;
  }

  async list(labOrganizationId: string, actorUserId: string, query: ListLabCasesDto) {
    await this.assertCanReadCases(actorUserId, labOrganizationId);

    if (query.prosthesisTypeCode) {
      this.prosthesisCatalog.assertKnownProsthesisType(query.prosthesisTypeCode);
    }

    const page = query.page ?? 1;
    const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
    const skip = (page - 1) * limit;

    const where = this.buildListWhere(labOrganizationId, query);

    const [items, total] = await Promise.all([
      this.prisma.labCase.findMany({
        where,
        include: {
          treatment: {
            include: {
              organization: { select: { id: true, name: true } },
              patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
            },
          },
          tasks: {
            select: {
              id: true,
              status: true,
              prosthesisTypeCode: true,
              teeth: true,
              selectionGroupId: true,
            },
          },
        },
        orderBy: [{ sentAt: 'desc' }],
        skip,
        take: limit,
      }),
      this.prisma.labCase.count({ where }),
    ]);

    const unreadCaseIds = await this.labCaseActivity.unreadCaseIdsInBatch(
      actorUserId,
      labOrganizationId,
      items.map((lc) => lc.id),
      LAB_CASES_TAB_ACTIVITY_TYPES,
      'LAB',
    );

    return {
      success: true,
      data: {
        items: items.map((lc) => ({
          ...this.mapLabCaseListItem(lc),
          hasUnread: unreadCaseIds.has(lc.id),
        })),
        pagination: {
          page,
          limit,
          total,
          totalPages: Math.max(1, Math.ceil(total / limit)),
        },
      },
    };
  }

  async listFilterOptions(labOrganizationId: string, actorUserId: string) {
    await this.assertCanReadCases(actorUserId, labOrganizationId);

    const [clinicRows, taskRows] = await Promise.all([
      this.prisma.labCase.findMany({
        where: {
          sentAt: { not: null },
          sends: { some: { organizationId: labOrganizationId } },
        },
        select: {
          treatment: {
            select: {
              organization: { select: { id: true, name: true } },
            },
          },
        },
      }),
      this.prisma.labCaseTask.findMany({
        where: {
          labCase: {
            sentAt: { not: null },
            sends: { some: { organizationId: labOrganizationId } },
          },
        },
        select: { prosthesisTypeCode: true },
        distinct: ['prosthesisTypeCode'],
      }),
    ]);

    const clinicsById = new Map<string, { id: string; name: string }>();
    for (const row of clinicRows) {
      clinicsById.set(row.treatment.organization.id, row.treatment.organization);
    }

    const typeCodes = new Set(taskRows.map((row) => row.prosthesisTypeCode));
    const catalog = await this.prosthesisCatalog.list();
    const prosthesisTypes = catalog
      .filter((entry) => typeCodes.has(entry.code))
      .map((entry) => ({ code: entry.code }));

    return {
      success: true,
      data: {
        clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)),
        prosthesisTypes,
      },
    };
  }

  /** Cases exchanged between one clinic and one lab (Organizations connection history). */
  async listBetweenOrganizations(
    clinicOrganizationId: string,
    labOrganizationId: string,
    query: ListLabCasesDto,
  ) {
    if (query.prosthesisTypeCode) {
      this.prosthesisCatalog.assertKnownProsthesisType(query.prosthesisTypeCode);
    }

    const page = query.page ?? 1;
    const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
    const skip = (page - 1) * limit;

    const where: Prisma.LabCaseWhereInput = {
      ...this.buildListWhere(labOrganizationId, query),
      treatment: { organizationId: clinicOrganizationId },
      sends: { some: { organizationId: labOrganizationId } },
    };

    const [items, total] = await Promise.all([
      this.prisma.labCase.findMany({
        where,
        include: {
          treatment: {
            include: {
              organization: { select: { id: true, name: true } },
              patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
            },
          },
          tasks: {
            select: {
              id: true,
              status: true,
              prosthesisTypeCode: true,
              teeth: true,
              selectionGroupId: true,
            },
          },
        },
        orderBy: [{ sentAt: 'desc' }],
        skip,
        take: limit,
      }),
      this.prisma.labCase.count({ where }),
    ]);

    return {
      success: true,
      data: {
        items: items.map((lc) => this.mapLabCaseListItem(lc)),
        pagination: {
          page,
          limit,
          total,
          totalPages: Math.max(1, Math.ceil(total / limit)),
        },
      },
    };
  }

  async getOneBetweenOrganizations(
    labCaseId: string,
    clinicOrganizationId: string,
    labOrganizationId: string,
    localeInput?: string | null,
  ) {
    const labCase = await this.prisma.labCase.findFirst({
      where: {
        id: labCaseId,
        sentAt: { not: null },
        treatment: { organizationId: clinicOrganizationId },
        sends: { some: { organizationId: labOrganizationId } },
      },
      include: labCaseListInclude,
    });

    if (!labCase) {
      throw new NotFoundException('Case not found');
    }

    return {
      success: true,
      data: await this.mapLabCaseDetail(labCase, localeInput),
    };
  }

  async getOne(
    labCaseId: string,
    labOrganizationId: string,
    actorUserId: string,
    localeInput?: string | null,
  ) {
    await this.assertCanReadCases(actorUserId, labOrganizationId);

    const labCase = await this.prisma.labCase.findFirst({
      where: {
        id: labCaseId,
        sentAt: { not: null },
        sends: { some: { organizationId: labOrganizationId } },
      },
      include: labCaseListInclude,
    });

    if (!labCase) {
      throw new NotFoundException('Case not found');
    }

    return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
  }

  async streamCaseAttachment(
    labCaseId: string,
    attachmentId: string,
    labOrganizationId: string,
    actorUserId: string,
  ) {
    await this.assertCanReadCases(actorUserId, labOrganizationId);

    const link = await this.prisma.labCaseAttachment.findFirst({
      where: {
        labCaseId,
        attachmentId,
        labCase: {
          sentAt: { not: null },
          sends: { some: { organizationId: labOrganizationId } },
        },
      },
      include: {
        attachment: { select: { storagePath: true, fileName: true, mimeType: true } },
      },
    });

    if (!link?.attachment) {
      throw new NotFoundException('Attachment not found');
    }

    if (!existsSync(link.attachment.storagePath)) {
      throw new NotFoundException('Attachment file is missing on disk');
    }

    return {
      stream: createReadStream(link.attachment.storagePath),
      fileName: link.attachment.fileName,
      mimeType: link.attachment.mimeType,
    };
  }

  async setCaseImportant(
    labCaseId: string,
    dto: UpdateLabCaseImportantDto,
    labOrganizationId: string,
    actorUserId: string,
    localeInput?: string | null,
  ) {
    await this.assertCanEditCases(actorUserId, labOrganizationId);

    const existing = await this.prisma.labCase.findFirst({
      where: {
        id: labCaseId,
        sentAt: { not: null },
        sends: { some: { organizationId: labOrganizationId } },
      },
      select: { id: true, isImportant: true },
    });

    if (!existing) {
      throw new NotFoundException('Case not found');
    }

    await this.prisma.labCase.update({
      where: { id: labCaseId },
      data: { isImportant: dto.isImportant },
    });

    if (dto.isImportant && !existing.isImportant) {
      await this.labCaseActivity.record({
        labCaseId,
        type: LabCaseActivityType.CASE_IMPORTANT,
        actorUserId,
      });
      void this.userNotifications.notify({
        organizationId: labOrganizationId,
        type: UserNotificationType.CASE_IMPORTANT,
        href: `/cases?caseId=${encodeURIComponent(labCaseId)}`,
        actorUserId,
        payload: { labCaseId },
        requiredPermission: 'TAB_CASES_READ',
      });
    }

    const labCase = await this.prisma.labCase.findFirstOrThrow({
      where: { id: labCaseId },
      include: labCaseListInclude,
    });

    return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
  }

  async setCaseExternalCode(
    labCaseId: string,
    dto: UpdateLabCaseExternalCodeDto,
    labOrganizationId: string,
    actorUserId: string,
    localeInput?: string | null,
  ) {
    await this.assertCanEditCases(actorUserId, labOrganizationId);

    const existing = await this.prisma.labCase.findFirst({
      where: {
        id: labCaseId,
        sentAt: { not: null },
        sends: { some: { organizationId: labOrganizationId } },
      },
      select: { id: true },
    });

    if (!existing) {
      throw new NotFoundException('Case not found');
    }

    const externalCode =
      dto.externalCode == null || !String(dto.externalCode).trim()
        ? null
        : String(dto.externalCode).trim().slice(0, 64);

    await this.prisma.labCase.update({
      where: { id: labCaseId },
      data: { externalCode },
    });

    const labCase = await this.prisma.labCase.findFirstOrThrow({
      where: { id: labCaseId },
      include: labCaseListInclude,
    });

    return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
  }

  async listAssignableStaff(labOrganizationId: string, actorUserId: string) {
    await this.assertCanEditCases(actorUserId, labOrganizationId);

    const memberships = await this.prisma.membership.findMany({
      where: {
        organizationId: labOrganizationId,
        OR: [{ isOwner: true }, { isActive: true }],
      },
      include: {
        permissions: { include: { permission: true } },
        organization: { include: { type: true, plan: true } },
        user: { select: { id: true, name: true } },
      },
    });

    const staff = memberships
      .filter((m) => hasEffectivePermission(m, 'TAB_TASKS_EDIT'))
      .map((m) => ({
        id: m.user.id,
        name: m.user.name,
      }))
      .sort((a, b) => a.name.localeCompare(b.name));

    return { success: true, data: staff };
  }

  async assignTask(
    labCaseId: string,
    taskId: string,
    dto: AssignLabCaseTaskDto,
    labOrganizationId: string,
    actorUserId: string,
    localeInput?: string | null,
  ) {
    await this.assertCanEditCases(actorUserId, labOrganizationId);

    const task = await this.prisma.labCaseTask.findFirst({
      where: {
        id: taskId,
        labCaseId,
        labCase: {
          sentAt: { not: null },
          sends: { some: { organizationId: labOrganizationId } },
        },
      },
      select: { id: true },
    });

    if (!task) {
      throw new NotFoundException('Task not found');
    }

    const assigneeUserId = dto.assigneeUserId ?? null;

    if (assigneeUserId) {
      const memberships = await this.prisma.membership.findMany({
        where: {
          organizationId: labOrganizationId,
          userId: assigneeUserId,
          OR: [{ isOwner: true }, { isActive: true }],
        },
        include: {
          permissions: { include: { permission: true } },
          organization: { include: { type: true, plan: true } },
        },
      });

      const canReceive = memberships.some((m) => hasEffectivePermission(m, 'TAB_TASKS_EDIT'));
      if (!canReceive) {
        throw new BadRequestException('Selected user cannot be assigned tasks');
      }
    }

    await this.prisma.labCaseTask.update({
      where: { id: taskId },
      data: {
        assigneeUserId,
        assignedAt: assigneeUserId ? new Date() : null,
      },
    });

    if (assigneeUserId) {
      await this.labCaseActivity.record({
        labCaseId,
        type: LabCaseActivityType.TASK_ASSIGNED,
        actorUserId,
        payload: { taskId, assigneeUserId },
      });

      void this.userNotifications.notify({
        organizationId: labOrganizationId,
        type: UserNotificationType.TASK_ASSIGNED,
        href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(labCaseId)}`,
        actorUserId,
        payload: { labCaseId, taskId, assigneeUserId },
        recipientUserIds: [assigneeUserId],
      });
    }

    const labCase = await this.prisma.labCase.findFirstOrThrow({
      where: { id: labCaseId },
      include: labCaseListInclude,
    });

    return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
  }

  private buildListWhere(
    labOrganizationId: string,
    query: ListLabCasesDto,
  ): Prisma.LabCaseWhereInput {
    const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null };

    if (query.sentFrom) {
      const from = new Date(query.sentFrom);
      if (Number.isNaN(from.getTime())) {
        throw new BadRequestException('Invalid sentFrom date');
      }
      sentAtFilter.gte = from;
    }

    if (query.sentTo) {
      const to = new Date(query.sentTo);
      if (Number.isNaN(to.getTime())) {
        throw new BadRequestException('Invalid sentTo date');
      }
      to.setHours(23, 59, 59, 999);
      sentAtFilter.lte = to;
    }

    return {
      sentAt: sentAtFilter,
      sends: { some: { organizationId: labOrganizationId } },
      ...(query.clinicOrganizationId
        ? { treatment: { organizationId: query.clinicOrganizationId } }
        : {}),
      ...(query.prosthesisTypeCode
        ? {
            tasks: {
              some: { prosthesisTypeCode: query.prosthesisTypeCode },
            },
          }
        : {}),
      ...(query.q?.trim() ? this.buildSearchWhere(query.q.trim()) : {}),
    };
  }

  private buildSearchWhere(q: string): Prisma.LabCaseWhereInput {
    const orConditions: Prisma.LabCaseWhereInput[] = [
      {
        treatment: {
          patient: {
            OR: [
              { firstName: { contains: q, mode: 'insensitive' } },
              { lastName: { contains: q, mode: 'insensitive' } },
            ],
          },
        },
      },
      {
        treatment: {
          organization: { name: { contains: q, mode: 'insensitive' } },
        },
      },
    ];

    const normalized = normalizeMobile(q);
    if (normalized) {
      orConditions.push({
        treatment: { patient: { mobile: normalized } },
      });
    }

    return { OR: orConditions };
  }

  private mapLabCaseListItem(lc: {
    id: string;
    sentAt: Date | null;
    dueDate: Date | null;
    isImportant: boolean;
    treatment: {
      organization: { id: string; name: string };
      patient: { id: string; firstName: string; lastName: string; mobile: string };
    };
    tasks: Array<{
      id: string;
      status: LabTaskStatus;
      prosthesisTypeCode: string;
      teeth: Prisma.JsonValue;
    }>;
  }) {
    const prosthesisGroups = this.buildProsthesisGroupsFromTasks(lc.tasks);
    const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length;

    return {
      id: lc.id,
      sentAt: lc.sentAt?.toISOString() ?? null,
      dueDate: lc.dueDate?.toISOString() ?? null,
      isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
      isImportant: lc.isImportant,
      clinic: lc.treatment.organization,
      patient: {
        id: lc.treatment.patient.id,
        firstName: lc.treatment.patient.firstName,
        lastName: lc.treatment.patient.lastName,
        mobile: lc.treatment.patient.mobile,
      },
      prosthesisGroups,
      taskProgress: {
        completed: completedTasks,
        total: lc.tasks.length,
      },
    };
  }

  private async mapLabCaseDetail(
    lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>,
    localeInput?: string | null,
  ) {
    const locale = normalizeCatalogLocale(localeInput);
    const treatmentType = lc.details[0]?.detail.treatmentType ?? null;
    const link = lc.details[0];
    const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))];
    const prosthesisLabels = await this.catalogLabels.resolveLabels(
      CatalogEntityKind.PROSTHESIS_TYPE,
      prosthesisCodes,
      locale,
    );
    const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels);
    const accessToken = lc.sentAt
      ? await this.labCaseAccess.ensureAccessToken(lc.id)
      : null;
    const shareUrl = accessToken
      ? this.labCaseAccess.buildShareUrl(accessToken, locale)
      : null;

    return {
      id: lc.id,
      sentAt: lc.sentAt?.toISOString() ?? null,
      dueDate: lc.dueDate?.toISOString() ?? null,
      isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
      isImportant: lc.isImportant,
      externalCode: lc.externalCode ?? null,
      shareUrl,
      clinic: lc.treatment.organization,
      patient: lc.treatment.patient,
      appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
      treatmentType,
      detail: link
        ? {
            id: link.detail.id,
            treatmentType: link.detail.treatmentType,
            teeth: normalizeTeeth(link.detail.teeth),
            comment: link.detail.comment,
          }
        : null,
      toothProsthesis: lc.toothProsthesis.map((row) => ({
        treatmentDetailId: row.treatmentDetailId,
        tooth: row.tooth,
        prosthesisTypeCode: row.prosthesisTypeCode,
        selectionGroupId: row.selectionGroupId?.trim() || undefined,
      })),
      attachments: lc.attachments.map((row) => ({
        id: row.attachment.id,
        fileName: row.attachment.fileName,
        mimeType: row.attachment.mimeType,
        sizeBytes: row.attachment.sizeBytes,
        createdAt: row.attachment.createdAt.toISOString(),
      })),
      sends: lc.sends.map((s) => ({
        organizationId: s.organizationId,
        organizationName: s.organization.name,
        sentAt: s.sentAt.toISOString(),
      })),
      tasks: lc.tasks.map((t) => this.mapTask(t, prosthesisLabels)),
      tasksByTooth,
      taskProgress: {
        completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length,
        total: lc.tasks.length,
      },
    };
  }

  private buildProsthesisGroupsFromTasks(
    tasks: Array<{
      prosthesisTypeCode: string;
      teeth: Prisma.JsonValue;
      selectionGroupId?: string;
    }>,
  ): Array<{
    prosthesisTypeCode: string;
    teeth: string[];
    connected?: boolean;
    selectionGroupId?: string;
  }> {
    const prosthesisByGroup = new Map<
      string,
      { prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string }
    >();

    for (const task of tasks) {
      if (!task.prosthesisTypeCode) continue;
      const selectionGroupId = task.selectionGroupId ?? '';
      const key = `${selectionGroupId}::${task.prosthesisTypeCode}`;
      const teeth = normalizeTaskTeeth(task.teeth);
      const entry = prosthesisByGroup.get(key) ?? {
        prosthesisTypeCode: task.prosthesisTypeCode,
        teeth: [],
        selectionGroupId,
      };
      entry.teeth.push(...teeth);
      prosthesisByGroup.set(key, entry);
    }

    return [...prosthesisByGroup.values()]
      .map((group) => ({
        prosthesisTypeCode: group.prosthesisTypeCode,
        teeth: [...new Set(group.teeth)].sort((a, b) =>
          a.localeCompare(b, undefined, { numeric: true }),
        ),
        selectionGroupId: group.selectionGroupId || undefined,
        connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1,
      }))
      .sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
  }

  private groupTasks(
    tasks: LabCaseTaskWithRelations[],
    prosthesisLabels: Map<string, string>,
  ) {
    const groups = new Map<
      string,
      {
        treatmentDetailId: string;
        teeth: string[];
        treatmentType: string;
        prosthesisTypeCode: string;
        prosthesisTypeLabel: string;
        selectionGroupId: string;
        tasks: ReturnType<CasesService['mapTask']>[];
      }
    >();

    for (const task of tasks) {
      const selectionGroupId = task.selectionGroupId ?? '';
      const key = `${task.treatmentDetailId}:${selectionGroupId}:${task.prosthesisTypeCode}`;
      const entry = groups.get(key) ?? {
        treatmentDetailId: task.treatmentDetailId,
        teeth: normalizeTaskTeeth(task.teeth),
        treatmentType: task.treatmentType,
        prosthesisTypeCode: task.prosthesisTypeCode,
        prosthesisTypeLabel:
          prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
        selectionGroupId,
        tasks: [],
      };
      entry.tasks.push(this.mapTask(task, prosthesisLabels));
      groups.set(key, entry);
    }

    return [...groups.values()].map((group) => ({
      treatmentDetailId: group.treatmentDetailId,
      teeth: [...new Set(group.teeth)],
      treatmentType: group.treatmentType,
      prosthesisTypeCode: group.prosthesisTypeCode,
      prosthesisTypeLabel: group.prosthesisTypeLabel,
      selectionGroupId: group.selectionGroupId || undefined,
      connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1,
      tasks: group.tasks,
    }));
  }

  private mapTask(
    task: LabCaseTaskWithRelations,
    prosthesisLabels: Map<string, string>,
  ) {
    return {
      id: task.id,
      treatmentDetailId: task.treatmentDetailId,
      teeth: normalizeTaskTeeth(task.teeth),
      treatmentType: task.treatmentType,
      prosthesisTypeCode: task.prosthesisTypeCode,
      prosthesisTypeLabel:
        prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
      workflowStepCode: task.workflowStepCode ?? '',
      stepOrder: task.stepOrder,
      stepLabel: task.stepLabel,
      status: task.status,
      assignee: task.assignee
        ? { id: task.assignee.id, name: task.assignee.name }
        : null,
      assignedAt: task.assignedAt?.toISOString() ?? null,
      createdAt: task.createdAt.toISOString(),
      lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
      lastStatusChangedBy: task.lastStatusChangedBy
        ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
        : null,
      timeline: task.statusEvents.map((event) => ({
        id: event.id,
        fromStatus: event.fromStatus,
        toStatus: event.toStatus,
        changedAt: event.changedAt.toISOString(),
        changedBy: event.changedBy
          ? { id: event.changedBy.id, name: event.changedBy.name }
          : null,
      })),
    };
  }

  private async assertCanReadCases(userId: string, organizationId: string) {
    const m = await this.getMembership(userId, organizationId);
    if (!m) {
      throw new ForbiddenException('You are not a member of this organization');
    }
    if (m.isOwner) return;
    const names = m.permissions.map((p) => p.permission.name);
    if (names.includes('TAB_CASES_READ') || names.includes('TAB_CASES_EDIT')) {
      return;
    }
    throw new ForbiddenException('You do not have access to cases');
  }

  private async assertCanEditCases(userId: string, organizationId: string) {
    const m = await this.getMembership(userId, organizationId);
    if (!m) {
      throw new ForbiddenException('You are not a member of this organization');
    }
    if (m.isOwner) return;
    const names = m.permissions.map((p) => p.permission.name);
    if (names.includes('TAB_CASES_EDIT')) {
      return;
    }
    throw new ForbiddenException('You cannot update cases');
  }

  private async getMembership(userId: string, organizationId: string) {
    return this.prisma.membership.findFirst({
      where: { userId, organizationId, isActive: true },
      include: { permissions: { include: { permission: true } } },
    });
  }
}
