import {
  HttpStatus,
  Injectable,
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { randomUUID } from 'crypto';
import { createReadStream, existsSync, mkdirSync, unlinkSync } from 'fs';
import { join } from 'path';
import { CatalogEntityKind, LabCaseActivityType, LabCaseOrigin, LabTaskStatus, LinkStatus, 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, CreateLabInternalCaseDto, UpdateLabInternalCaseDto } from './dto/cases.dto';
import { generateLabCaseTasks } from './lab-case-task.generator';
import {
  isLabCaseOverdue,
} from '../../common/lab-case-due-date';
import {
  aggregateProsthesisGroupsFromTasks,
  connectedSelectionGroupIds,
  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';
import {
  atomicProsthesisCodes,
  prosthesisGroupLabel,
  prosthesisTypeCaseWhere,
} from '../../common/prosthesis-group';

const labCaseListInclude = {
  treatment: {
    include: {
      organization: { select: { id: true, name: true } },
      patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
      appointment: { select: { startAt: true } },
    },
  },
  partnerClinic: { select: { id: true, name: true } },
  lines: { orderBy: [{ sortOrder: 'asc' as const }] },
  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,
          detailClientKey: 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 {
  private readonly uploadRoot = join(process.cwd(), 'uploads', 'lab-cases');

  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 AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
    }
    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 } },
            },
          },
          partnerClinic: { select: { id: true, name: true } },
          tasks: {
            select: {
              id: true,
              status: true,
              prosthesisTypeCode: true,
              teeth: true,
              selectionGroupId: true,
            },
          },
        },
        orderBy: [{ startedAt: 'desc' }, { sentAt: 'desc' }, { id: '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: this.visibleToLabWhere(labOrganizationId),
        select: {
          partnerClinic: { select: { id: true, name: true } },
          treatment: {
            select: {
              organization: { select: { id: true, name: true } },
            },
          },
        },
      }),
      this.prisma.labCaseTask.findMany({
        where: {
          labCase: this.visibleStartedToLabWhere(labOrganizationId),
        },
        select: { prosthesisTypeCode: true },
        distinct: ['prosthesisTypeCode'],
      }),
    ]);

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

    const typeCodes = new Set(atomicProsthesisCodes(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 AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
    }

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

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

    const [linksA, linksB] = await Promise.all([
      this.prisma.organizationLink.findMany({
        where: { organizationAId: labOrganizationId, status: LinkStatus.ACTIVE },
        include: {
          organizationB: { select: { id: true, name: true, type: { select: { name: true } } } },
        },
      }),
      this.prisma.organizationLink.findMany({
        where: { organizationBId: labOrganizationId, status: LinkStatus.ACTIVE },
        include: {
          organizationA: { select: { id: true, name: true, type: { select: { name: true } } } },
        },
      }),
    ]);

    const data = [
      ...linksA.map((l) => ({
        id: l.organizationB.id,
        name: l.organizationB.name,
        type: l.organizationB.type.name,
      })),
      ...linksB.map((l) => ({
        id: l.organizationA.id,
        name: l.organizationA.name,
        type: l.organizationA.type.name,
      })),
    ]
      .filter((o) => o.type === 'CLINIC')
      .map(({ id, name }) => ({ id, name, active: true }))
      .sort((a, b) => a.name.localeCompare(b.name));

    return { success: true, data };
  }

  async createInternal(
    dto: CreateLabInternalCaseDto,
    labOrganizationId: string,
    actorUserId: string,
    localeInput?: string | null,
  ) {
    await this.assertCanEditCases(actorUserId, labOrganizationId);
    await this.assertPartnerClinic(dto.partnerClinicOrganizationId, labOrganizationId);

    const created = await this.prisma.labCase.create({
      data: {
        origin: LabCaseOrigin.LAB_INTERNAL,
        destinationOrganizationId: labOrganizationId,
        referringClinicName: dto.referringClinicName?.trim() || null,
        referringDentistName: dto.referringDentistName?.trim() || null,
        patientDisplayName: dto.patientDisplayName?.trim() || null,
        patientDisplayMobile: dto.patientDisplayMobile?.trim() || null,
        partnerClinicOrganizationId: dto.partnerClinicOrganizationId || null,
        dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
        sortOrder: 0,
      },
      include: labCaseListInclude,
    });

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

  async updateInternal(
    labCaseId: string,
    dto: UpdateLabInternalCaseDto,
    labOrganizationId: string,
    actorUserId: string,
    localeInput?: string | null,
  ) {
    await this.assertCanEditCases(actorUserId, labOrganizationId);
    const existing = await this.prisma.labCase.findFirst({
      where: {
        id: labCaseId,
        origin: LabCaseOrigin.LAB_INTERNAL,
        destinationOrganizationId: labOrganizationId,
      },
      select: { id: true, startedAt: true },
    });
    if (!existing) {
      throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
    }
    if (existing.startedAt) {
      throw new AppException(ErrorCode.LAB_CASE_NOT_EDITABLE, HttpStatus.CONFLICT);
    }

    await this.assertPartnerClinic(dto.partnerClinicOrganizationId, labOrganizationId);

    const saved = await this.prisma.$transaction(async (tx) => {
      await tx.labCase.update({
        where: { id: labCaseId },
        data: {
          referringClinicName: dto.referringClinicName?.trim() || null,
          referringDentistName: dto.referringDentistName?.trim() || null,
          patientDisplayName: dto.patientDisplayName?.trim() || null,
          patientDisplayMobile: dto.patientDisplayMobile?.trim() || null,
          partnerClinicOrganizationId: dto.partnerClinicOrganizationId || null,
          dueDate:
            dto.dueDate === undefined ? undefined : dto.dueDate ? new Date(dto.dueDate) : null,
        },
      });

      if (dto.lines) {
        const keepIds = dto.lines.map((l) => l.id).filter(Boolean) as string[];
        await tx.labCaseLine.deleteMany({
          where: {
            labCaseId,
            ...(keepIds.length ? { id: { notIn: keepIds } } : {}),
          },
        });

        for (const [index, line] of dto.lines.entries()) {
          if (line.id) {
            const owned = await tx.labCaseLine.findFirst({
              where: { id: line.id, labCaseId },
              select: { id: true },
            });
            if (!owned) {
              throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.BAD_REQUEST);
            }
          }

          const teeth = normalizeTeeth(line.teeth);
          const row = line.id
            ? await tx.labCaseLine.update({
                where: { id: line.id },
                data: {
                  clientKey: line.clientId,
                  sortOrder: index,
                  teeth,
                  toothSelectionGroups: (line.toothSelectionGroups ??
                    undefined) as Prisma.InputJsonValue | undefined,
                  comment: line.comment?.trim() || null,
                },
              })
            : await tx.labCaseLine.create({
                data: {
                  labCaseId,
                  clientKey: line.clientId,
                  sortOrder: index,
                  treatmentType: 'prosthesis',
                  teeth,
                  toothSelectionGroups: (line.toothSelectionGroups ??
                    undefined) as Prisma.InputJsonValue | undefined,
                  comment: line.comment?.trim() || null,
                },
              });

          await tx.labCaseToothProsthesis.deleteMany({ where: { lineId: row.id } });
          if (line.toothProsthesis?.length) {
            for (const tp of line.toothProsthesis) {
              this.prosthesisCatalog.assertKnownProsthesisType(tp.prosthesisTypeCode);
            }
            await tx.labCaseToothProsthesis.createMany({
              data: line.toothProsthesis.map((tp) => ({
                labCaseId,
                lineId: row.id,
                sourceKey: row.id,
                treatmentType: 'prosthesis',
                tooth: tp.tooth,
                prosthesisTypeCode: tp.prosthesisTypeCode,
                selectionGroupId: tp.selectionGroupId?.trim() || '',
              })),
            });
          }

          if (line.attachmentIds) {
            const lineAttachments = await tx.treatmentDetailAttachment.findMany({
              where: {
                detailClientKey: line.clientId,
                labCaseLinks: { some: { labCaseId } },
              },
              select: { id: true },
            });
            const keep = new Set(line.attachmentIds);
            const removeIds = lineAttachments.map((a) => a.id).filter((id) => !keep.has(id));
            if (removeIds.length) {
              await tx.labCaseAttachment.deleteMany({
                where: { labCaseId, attachmentId: { in: removeIds } },
              });
            }
            if (line.attachmentIds.length) {
              await tx.labCaseAttachment.createMany({
                data: line.attachmentIds.map((attachmentId) => ({ labCaseId, attachmentId })),
                skipDuplicates: true,
              });
            }
          }
        }
      }

      return tx.labCase.findFirstOrThrow({
        where: { id: labCaseId },
        include: labCaseListInclude,
      });
    });

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

  async startInternal(
    labCaseId: string,
    labOrganizationId: string,
    actorUserId: string,
    localeInput?: string | null,
  ) {
    await this.assertCanEditCases(actorUserId, labOrganizationId);
    const existing = await this.prisma.labCase.findFirst({
      where: {
        id: labCaseId,
        origin: LabCaseOrigin.LAB_INTERNAL,
        destinationOrganizationId: labOrganizationId,
      },
      include: { lines: true, toothProsthesis: true },
    });
    if (!existing) {
      throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
    }
    if (existing.startedAt) {
      throw new AppException(ErrorCode.LAB_CASE_ALREADY_STARTED, HttpStatus.CONFLICT);
    }
    if (existing.lines.length === 0 || existing.toothProsthesis.length === 0) {
      throw new AppException(ErrorCode.LAB_CASE_START_INCOMPLETE, HttpStatus.BAD_REQUEST);
    }
    const hasClient =
      Boolean(existing.referringClinicName?.trim()) ||
      Boolean(existing.patientDisplayName?.trim()) ||
      Boolean(existing.partnerClinicOrganizationId);
    if (!hasClient) {
      throw new AppException(ErrorCode.LAB_CASE_CLIENT_REQUIRED, HttpStatus.BAD_REQUEST);
    }

    await this.prisma.$transaction(async (tx) => {
      await tx.labCase.update({
        where: { id: labCaseId },
        data: { startedAt: new Date() },
      });
      await generateLabCaseTasks(tx, labCaseId, localeInput);
    });

    const refreshed = await this.prisma.labCase.findFirstOrThrow({
      where: { id: labCaseId },
      include: labCaseListInclude,
    });
    return { success: true, data: await this.mapLabCaseDetail(refreshed, localeInput) };
  }

  async deleteInternalDraft(
    labCaseId: string,
    labOrganizationId: string,
    actorUserId: string,
  ) {
    await this.assertCanEditCases(actorUserId, labOrganizationId);
    const existing = await this.prisma.labCase.findFirst({
      where: {
        id: labCaseId,
        origin: LabCaseOrigin.LAB_INTERNAL,
        destinationOrganizationId: labOrganizationId,
      },
      select: {
        id: true,
        startedAt: true,
        attachments: { include: { attachment: { select: { id: true, storagePath: true } } } },
      },
    });
    if (!existing) {
      throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
    }
    if (existing.startedAt) {
      throw new AppException(ErrorCode.LAB_CASE_ALREADY_STARTED, HttpStatus.CONFLICT);
    }

    const orphanAttachments = existing.attachments.map((row) => row.attachment);
    const orphanIds = orphanAttachments.map((row) => row.id);

    await this.prisma.$transaction(async (tx) => {
      await tx.labCase.delete({ where: { id: labCaseId } });
      if (orphanIds.length > 0) {
        await tx.treatmentDetailAttachment.deleteMany({
          where: { id: { in: orphanIds }, detailId: null },
        });
      }
    });

    for (const attachment of orphanAttachments) {
      try {
        if (attachment.storagePath && existsSync(attachment.storagePath)) {
          unlinkSync(attachment.storagePath);
        }
      } catch {
        // Best-effort disk cleanup; the draft row is already gone.
      }
    }

    return { success: true };
  }

  async uploadInternalAttachments(
    labCaseId: string,
    lineClientKey: string,
    files: Express.Multer.File[],
    labOrganizationId: string,
    actorUserId: string,
  ) {
    await this.assertCanEditCases(actorUserId, labOrganizationId);
    const existing = await this.prisma.labCase.findFirst({
      where: {
        id: labCaseId,
        origin: LabCaseOrigin.LAB_INTERNAL,
        destinationOrganizationId: labOrganizationId,
      },
      select: { id: true, startedAt: true },
    });
    if (!existing) {
      throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
    }
    if (existing.startedAt) {
      throw new AppException(ErrorCode.LAB_CASE_NOT_EDITABLE, HttpStatus.CONFLICT);
    }
    if (!lineClientKey?.trim()) {
      throw new AppException(ErrorCode.TREATMENT_DETAIL_KEY_REQUIRED, HttpStatus.BAD_REQUEST);
    }
    if (!files?.length) {
      throw new AppException(ErrorCode.TREATMENT_FILE_REQUIRED, HttpStatus.BAD_REQUEST);
    }

    const orgDir = join(this.uploadRoot, labOrganizationId);
    mkdirSync(orgDir, { recursive: true });

    const created: {
      id: string;
      fileName: string;
      mimeType: string;
      sizeBytes: number;
    }[] = [];

    for (const file of files) {
      const storageName = `${randomUUID()}-${file.originalname.replace(/[^\w.\-()+]/g, '_')}`;
      const storagePath = join(orgDir, storageName);
      const { writeFileSync } = await import('fs');
      writeFileSync(storagePath, file.buffer);

      const attachment = await this.prisma.treatmentDetailAttachment.create({
        data: {
          detailClientKey: lineClientKey,
          fileName: file.originalname,
          mimeType: file.mimetype || 'application/octet-stream',
          sizeBytes: file.size,
          storagePath,
        },
      });
      await this.prisma.labCaseAttachment.create({
        data: { labCaseId, attachmentId: attachment.id },
      });
      created.push({
        id: attachment.id,
        fileName: attachment.fileName,
        mimeType: attachment.mimeType,
        sizeBytes: attachment.sizeBytes,
      });
    }

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

  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,
        ...this.visibleToLabWhere(labOrganizationId),
      },
      include: labCaseListInclude,
    });

    if (!labCase) {
      throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.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: this.visibleToLabWhere(labOrganizationId),
      },
      include: {
        attachment: { select: { storagePath: true, fileName: true, mimeType: true } },
      },
    });

    if (!link?.attachment) {
      throw new AppException(ErrorCode.CASE_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
    }

    if (!existsSync(link.attachment.storagePath)) {
      throw new AppException(ErrorCode.CASE_ATTACHMENT_MISSING_FILE, HttpStatus.NOT_FOUND);
    }

    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,
        ...this.visibleToLabWhere(labOrganizationId),
      },
      select: { id: true, isImportant: true },
    });

    if (!existing) {
      throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.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,
        ...this.visibleToLabWhere(labOrganizationId),
      },
      select: { id: true },
    });

    if (!existing) {
      throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.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: this.visibleStartedToLabWhere(labOrganizationId),
      },
      select: { id: true },
    });

    if (!task) {
      throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.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 AppException(ErrorCode.TASK_ASSIGNEE_INVALID, HttpStatus.BAD_REQUEST);
      }
    }

    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 AppException(ErrorCode.INVALID_SENT_FROM, HttpStatus.BAD_REQUEST);
      }
      sentAtFilter.gte = from;
    }

    if (query.sentTo) {
      const to = new Date(query.sentTo);
      if (Number.isNaN(to.getTime())) {
        throw new AppException(ErrorCode.INVALID_SENT_TO, HttpStatus.BAD_REQUEST);
      }
      to.setHours(23, 59, 59, 999);
      sentAtFilter.lte = to;
    }

    const received: Prisma.LabCaseWhereInput = {
      sentAt: sentAtFilter,
      sends: { some: { organizationId: labOrganizationId } },
      ...(query.clinicOrganizationId
        ? { treatment: { organizationId: query.clinicOrganizationId } }
        : {}),
    };

    const ownedInternal: Prisma.LabCaseWhereInput = {
      origin: LabCaseOrigin.LAB_INTERNAL,
      destinationOrganizationId: labOrganizationId,
      ...(query.clinicOrganizationId
        ? {
            OR: [
              { partnerClinicOrganizationId: query.clinicOrganizationId },
            ],
          }
        : {}),
    };

    if (query.sentFrom || query.sentTo) {
      ownedInternal.startedAt = { ...sentAtFilter };
    }

    return {
      AND: [
        { OR: [received, ownedInternal] },
        ...(query.prosthesisTypeCode
          ? [prosthesisTypeCaseWhere(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' } },
        },
      },
      { referringClinicName: { contains: q, mode: 'insensitive' } },
      { referringDentistName: { contains: q, mode: 'insensitive' } },
      { patientDisplayName: { contains: q, mode: 'insensitive' } },
      { patientDisplayMobile: { contains: q, mode: 'insensitive' } },
    ];

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

    return { OR: orConditions };
  }

  private resolveClinicAndPatient(lc: {
    origin?: LabCaseOrigin;
    referringClinicName?: string | null;
    patientDisplayName?: string | null;
    patientDisplayMobile?: string | null;
    partnerClinic?: { id: string; name: string } | null;
    treatment?: {
      organization: { id: string; name: string };
      patient: { id: string; firstName: string; lastName: string; mobile: string };
    } | null;
  }) {
    const clinic =
      lc.treatment?.organization ??
      lc.partnerClinic ?? {
        id: '',
        name: lc.referringClinicName?.trim() || '',
      };
    const displayName = lc.patientDisplayName?.trim() || '';
    const [firstName, ...rest] = displayName.split(/\s+/);
    const patient = lc.treatment?.patient ?? {
      id: '',
      firstName: firstName || displayName || clinic.name || '—',
      lastName: rest.join(' '),
      mobile: lc.patientDisplayMobile?.trim() || '',
    };
    return { clinic, patient };
  }

  private mapLabCaseListItem(lc: {
    id: string;
    sentAt: Date | null;
    startedAt?: Date | null;
    dueDate: Date | null;
    isImportant: boolean;
    origin?: LabCaseOrigin;
    referringClinicName?: string | null;
    patientDisplayName?: string | null;
    patientDisplayMobile?: string | null;
    partnerClinic?: { id: string; name: string } | null;
    treatment: {
      organization: { id: string; name: string };
      patient: { id: string; firstName: string; lastName: string; mobile: string };
    } | null;
    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;
    const { clinic, patient } = this.resolveClinicAndPatient(lc);

    return {
      id: lc.id,
      sentAt: lc.sentAt?.toISOString() ?? null,
      startedAt: lc.startedAt?.toISOString() ?? null,
      origin: lc.origin ?? LabCaseOrigin.CLINIC_DISPATCH,
      dueDate: lc.dueDate?.toISOString() ?? null,
      isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
      isImportant: lc.isImportant,
      clinic,
      patient,
      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 ?? lc.lines[0]?.treatmentType ?? null;
    const link = lc.details[0];
    const { clinic, patient } = this.resolveClinicAndPatient(lc);
    const prosthesisCodes = atomicProsthesisCodes(lc.tasks.map((t) => t.prosthesisTypeCode));
    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,
      origin: lc.origin,
      startedAt: lc.startedAt?.toISOString() ?? null,
      referringClinicName: lc.referringClinicName,
      referringDentistName: lc.referringDentistName,
      patientDisplayName: lc.patientDisplayName,
      patientDisplayMobile: lc.patientDisplayMobile,
      partnerClinicOrganizationId: lc.partnerClinicOrganizationId,
      shareUrl,
      clinic,
      patient,
      appointmentStartAt: lc.treatment?.appointment?.startAt?.toISOString() ?? null,
      treatmentType,
      detail: link?.detail
        ? {
            id: link.detail.id,
            treatmentType: link.detail.treatmentType,
            teeth: normalizeTeeth(link.detail.teeth),
            comment: link.detail.comment,
          }
        : lc.lines[0]
          ? {
              id: lc.lines[0].id,
              treatmentType: lc.lines[0].treatmentType,
              teeth: normalizeTeeth(lc.lines[0].teeth),
              comment: lc.lines[0].comment,
            }
          : null,
      lines: lc.lines.map((line) => ({
        id: line.id,
        clientId: line.clientKey ?? line.id,
        treatmentType: line.treatmentType,
        teeth: normalizeTeeth(line.teeth),
        toothSelectionGroups: line.toothSelectionGroups,
        comment: line.comment,
      })),
      toothProsthesis: lc.toothProsthesis.map((row) => ({
        treatmentDetailId: row.treatmentDetailId,
        lineId: row.lineId,
        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(),
        detailClientKey: row.attachment.detailClientKey,
      })),
      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;
  }> {
    return aggregateProsthesisGroupsFromTasks(tasks);
  }

  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']>[];
      }
    >();

    const connectedIds = connectedSelectionGroupIds(tasks);

    for (const task of tasks) {
      const selectionGroupId = task.selectionGroupId ?? '';
      const key = `${task.sourceKey ?? task.treatmentDetailId ?? task.lineId}:${normalizeTaskTeeth(task.teeth).join(',')}:${task.prosthesisTypeCode}`;
      const entry = groups.get(key) ?? {
        treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '',
        teeth: normalizeTaskTeeth(task.teeth),
        treatmentType: task.treatmentType,
        prosthesisTypeCode: task.prosthesisTypeCode,
        prosthesisTypeLabel:
          prosthesisGroupLabel(task.prosthesisTypeCode, prosthesisLabels),
        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) && connectedIds.has(group.selectionGroupId),
      tasks: group.tasks,
    }));
  }

  private mapTask(
    task: LabCaseTaskWithRelations,
    prosthesisLabels: Map<string, string>,
  ) {
    return {
      id: task.id,
      treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '',
      teeth: normalizeTaskTeeth(task.teeth),
      treatmentType: task.treatmentType,
      prosthesisTypeCode: task.prosthesisTypeCode,
      prosthesisTypeLabel:
        prosthesisGroupLabel(task.prosthesisTypeCode, prosthesisLabels),
      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 visibleToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput {
    return {
      OR: [
        {
          sentAt: { not: null },
          sends: { some: { organizationId: labOrganizationId } },
        },
        {
          origin: LabCaseOrigin.LAB_INTERNAL,
          destinationOrganizationId: labOrganizationId,
        },
      ],
    };
  }

  private visibleStartedToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput {
    return {
      OR: [
        {
          sentAt: { not: null },
          sends: { some: { organizationId: labOrganizationId } },
        },
        {
          origin: LabCaseOrigin.LAB_INTERNAL,
          destinationOrganizationId: labOrganizationId,
          startedAt: { not: null },
        },
      ],
    };
  }

  private async assertPartnerClinic(
    partnerId: string | null | undefined,
    labOrganizationId: string,
  ) {
    if (!partnerId) return;
    const org = await this.prisma.organization.findUnique({
      where: { id: partnerId },
      select: { id: true, type: { select: { name: true } } },
    });
    if (!org || org.type.name !== 'CLINIC') {
      throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST);
    }
    const [linksA, linksB] = await Promise.all([
      this.prisma.organizationLink.findFirst({
        where: {
          organizationAId: labOrganizationId,
          organizationBId: partnerId,
          status: LinkStatus.ACTIVE,
        },
        select: { id: true },
      }),
      this.prisma.organizationLink.findFirst({
        where: {
          organizationAId: partnerId,
          organizationBId: labOrganizationId,
          status: LinkStatus.ACTIVE,
        },
        select: { id: true },
      }),
    ]);
    if (!linksA && !linksB) {
      throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST);
    }
  }

  private async assertCanReadCases(userId: string, organizationId: string) {
    const m = await this.getMembership(userId, organizationId);
    if (!m) {
      throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
    }
    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 AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN);
  }

  private async assertCanEditCases(userId: string, organizationId: string) {
    const m = await this.getMembership(userId, organizationId);
    if (!m) {
      throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
    }
    if (m.isOwner) return;
    const names = m.permissions.map((p) => p.permission.name);
    if (names.includes('TAB_CASES_EDIT')) {
      return;
    }
    throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN);
  }

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