import {
  HttpStatus,
  Injectable,
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { CatalogEntityKind, LabCaseActivityType, LabCaseOrigin, 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 { normalizeTaskTeeth } from '../cases/lab-case-task.util';
import { isLabCaseOverdue, startOfUtcDay } from '../../common/lab-case-due-date';
import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto';
import {
  atomicProsthesisCodes,
  prosthesisGroupLabel,
  prosthesisTypeTaskWhere,
} from '../../common/prosthesis-group';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import { UserNotificationService } from '../notifications/user-notification.service';

const taskListInclude = {
  lastStatusChangedBy: { select: { id: true, name: true } },
  assignee: { select: { id: true, name: true } },
  labCase: {
    include: {
      tasks: { select: { status: true } },
      partnerClinic: { select: { id: true, name: true } },
      treatment: {
        include: {
          organization: { select: { id: true, name: true } },
          patient: { select: { id: true, firstName: true, lastName: true } },
        },
      },
    },
  },
} satisfies Prisma.LabCaseTaskInclude;

/** Completing this step completes every matching task in the same case (catalog: first step of all prosthesis types). */
const CASE_SCOPED_SCAN_STEP_CODE = 'intraoral_scan';

@Injectable()
export class TasksService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly catalogLabels: CatalogLabelService,
    private readonly labCaseActivity: LabCaseActivityService,
    private readonly userNotifications: UserNotificationService,
  ) {}

  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: ListLabTasksDto,
    localeInput?: string | null,
  ) {
    await this.assertCanReadTasks(actorUserId, labOrganizationId);

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

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

    const [items, total] = await Promise.all([
      this.prisma.labCaseTask.findMany({
        where,
        include: taskListInclude,
        orderBy: this.buildOrderBy(query),
        skip,
        take: limit,
      }),
      this.prisma.labCaseTask.count({ where }),
    ]);

    const locale = normalizeCatalogLocale(localeInput);
    const prosthesisCodes = atomicProsthesisCodes(items.map((t) => t.prosthesisTypeCode));
    const prosthesisLabels = await this.catalogLabels.resolveLabels(
      CatalogEntityKind.PROSTHESIS_TYPE,
      prosthesisCodes,
      locale,
    );

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

  async listForLabCase(
    labCaseId: string,
    organizationId: string,
    actorUserId: string,
    localeInput?: string | null,
  ) {
    await this.assertCanReadTasks(actorUserId, organizationId);

    const labCase = await this.prisma.labCase.findFirst({
      where: {
        id: labCaseId,
        ...this.visibleStartedToLabWhere(organizationId),
      },
      select: { id: true },
    });

    if (!labCase) {
      throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
    }

    return this.listTasksForLabCaseId(labCaseId, localeInput);
  }

  async listTasksForLabCaseId(labCaseId: string, localeInput?: string | null) {
    const items = await this.prisma.labCaseTask.findMany({
      where: { labCaseId },
      include: taskListInclude,
      orderBy: [
        { treatmentDetailId: 'asc' },
        { sourceKey: 'asc' },
        { prosthesisTypeCode: 'asc' },
        { stepOrder: 'asc' },
        { id: 'asc' },
      ],
    });

    const locale = normalizeCatalogLocale(localeInput);
    const prosthesisCodes = atomicProsthesisCodes(items.map((t) => t.prosthesisTypeCode));
    const prosthesisLabels = await this.catalogLabels.resolveLabels(
      CatalogEntityKind.PROSTHESIS_TYPE,
      prosthesisCodes,
      locale,
    );

    return {
      success: true,
      data: {
        items: items.map((task) => this.mapTaskListItem(task, prosthesisLabels)),
      },
    };
  }

  async locateTaskPage(
    labOrganizationId: string,
    actorUserId: string,
    query: LocateTaskPageDto,
  ) {
    await this.assertCanReadTasks(actorUserId, labOrganizationId);

    const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
    const listQuery = this.toListQueryFromLocate(query);

    const target = await this.prisma.labCaseTask.findFirst({
      where: {
        id: query.taskId,
        labCase: this.visibleStartedToLabWhere(labOrganizationId),
      },
      include: {
        labCase: { select: { sentAt: true, startedAt: true } },
      },
    });

    const effectiveAt = target?.labCase.sentAt ?? target?.labCase.startedAt;
    if (!target || !effectiveAt) {
      throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND);
    }

    const where = await this.buildListWhere(labOrganizationId, actorUserId, listQuery);
    const inFilteredSet = await this.prisma.labCaseTask.count({
      where: { AND: [where, { id: query.taskId }] },
    });

    if (inFilteredSet === 0) {
      return {
        success: true,
        data: { page: 1, found: false, labCaseId: target.labCaseId },
      };
    }

    const position = await this.countTasksBeforeSortedPosition(
      where,
      listQuery,
      {
        sentAt: effectiveAt,
        labCaseId: target.labCaseId,
        sourceKey: target.sourceKey,
        tooth: target.tooth,
        prosthesisTypeCode: target.prosthesisTypeCode,
        stepOrder: target.stepOrder,
        id: target.id,
      },
    );

    return {
      success: true,
      data: {
        page: Math.floor(position / limit) + 1,
        found: true,
        labCaseId: target.labCaseId,
      },
    };
  }

  async updateStatus(
    taskId: string,
    dto: UpdateLabTaskDto,
    labOrganizationId: string,
    actorUserId: string,
    localeInput?: string | null,
  ) {
    await this.assertCanEditTasks(actorUserId, labOrganizationId);

    const task = await this.prisma.labCaseTask.findFirst({
      where: {
        id: taskId,
        labCase: this.visibleStartedToLabWhere(labOrganizationId),
      },
      include: taskListInclude,
    });

    if (!task) {
      throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND);
    }

    if (task.assigneeUserId && task.assigneeUserId !== actorUserId) {
      throw new AppException(ErrorCode.TASK_ASSIGNED_TO_OTHER, HttpStatus.FORBIDDEN);
    }

    const updated = await this.prisma.$transaction(async (tx) => {
      const now = new Date();
      const result = await tx.labCaseTask.update({
        where: { id: taskId },
        data: {
          status: dto.status,
          lastStatusChangedByUserId: actorUserId,
          lastStatusChangedAt: now,
        },
        include: taskListInclude,
      });

      if (task.status !== dto.status) {
        await tx.labCaseTaskStatusEvent.create({
          data: {
            taskId,
            fromStatus: task.status,
            toStatus: dto.status,
            changedByUserId: actorUserId,
          },
        });
      }

      const cascadedScanTaskIds: string[] = [];
      if (
        dto.status === LabTaskStatus.COMPLETED &&
        task.status !== LabTaskStatus.COMPLETED &&
        task.workflowStepCode === CASE_SCOPED_SCAN_STEP_CODE
      ) {
        const siblingScans = await tx.labCaseTask.findMany({
          where: {
            labCaseId: task.labCaseId,
            workflowStepCode: CASE_SCOPED_SCAN_STEP_CODE,
            status: { not: LabTaskStatus.COMPLETED },
            id: { not: taskId },
          },
          select: { id: true, status: true },
        });

        for (const sibling of siblingScans) {
          await tx.labCaseTask.update({
            where: { id: sibling.id },
            data: {
              status: LabTaskStatus.COMPLETED,
              lastStatusChangedByUserId: actorUserId,
              lastStatusChangedAt: now,
            },
          });
          await tx.labCaseTaskStatusEvent.create({
            data: {
              taskId: sibling.id,
              fromStatus: sibling.status,
              toStatus: LabTaskStatus.COMPLETED,
              changedByUserId: actorUserId,
            },
          });
          cascadedScanTaskIds.push(sibling.id);
        }
      }

      if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
        await this.labCaseActivity.record(
          {
            labCaseId: task.labCaseId,
            type: LabCaseActivityType.TASK_COMPLETED,
            actorUserId,
            payload: {
              taskId,
              ...(cascadedScanTaskIds.length > 0
                ? {
                    cascadedTaskIds: cascadedScanTaskIds,
                    caseScopedStep: CASE_SCOPED_SCAN_STEP_CODE,
                  }
                : {}),
            },
          },
          tx,
        );
      }

      return result;
    });

    if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
      void this.userNotifications.notify({
        organizationId: labOrganizationId,
        type: UserNotificationType.TASK_COMPLETED,
        href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(task.labCaseId)}`,
        actorUserId,
        payload: { labCaseId: task.labCaseId, taskId },
        requiredPermission: 'TAB_TASKS_READ',
      });

      const clinicOrgId = task.labCase?.treatment?.organization?.id;
      if (clinicOrgId) {
        void this.userNotifications.notify({
          organizationId: clinicOrgId,
          type: UserNotificationType.TASK_COMPLETED,
          href: `/treatment?labCaseId=${encodeURIComponent(task.labCaseId)}`,
          actorUserId,
          payload: { labCaseId: task.labCaseId, taskId },
          requiredPermission: 'TAB_TREATMENT_READ',
          labCaseIdForProviderScope: task.labCaseId,
        });
      }
    }

    const locale = normalizeCatalogLocale(localeInput);
    const prosthesisLabels = await this.catalogLabels.resolveLabels(
      CatalogEntityKind.PROSTHESIS_TYPE,
      atomicProsthesisCodes([updated.prosthesisTypeCode]),
      locale,
    );

    return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
  }

  async listFilterOptions(
    labOrganizationId: string,
    actorUserId: string,
    localeInput?: string | null,
  ) {
    await this.assertCanReadTasks(actorUserId, labOrganizationId);

    const rows = await this.prisma.labCase.findMany({
      where: this.visibleStartedToLabWhere(labOrganizationId),
      select: {
        partnerClinic: { select: { id: true, name: true } },
        treatment: {
          select: {
            organization: { select: { id: true, name: true } },
          },
        },
      },
    });

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

    const locale = normalizeCatalogLocale(localeInput);
    const steps = await this.prisma.labWorkflowStep.findMany({
      orderBy: { sortOrder: 'asc' },
      select: { code: true },
    });
    const stepCodes = steps.map((s) => s.code);
    const stepLabels = await this.catalogLabels.resolveLabels(
      CatalogEntityKind.LAB_WORKFLOW_STEP,
      stepCodes,
      locale,
    );

    return {
      success: true,
      data: {
        clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)),
        workflowSteps: steps.map((step) => ({
          code: step.code,
          label: stepLabels.get(step.code) ?? step.code,
        })),
      },
    };
  }

  private async buildListWhere(
    labOrganizationId: string,
    actorUserId: string,
    query: ListLabTasksDto,
  ): Promise<Prisma.LabCaseTaskWhereInput> {
    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;
    }

    // Status: explicit status wins; completed=true/false narrows; otherwise no status filter.
    let status: LabTaskStatus | undefined;
    if (query.status) {
      status = query.status;
    } else if (query.completed === true) {
      status = LabTaskStatus.COMPLETED;
    } else if (query.completed === false) {
      status = LabTaskStatus.IN_PROGRESS;
    }

    const labCaseScope: Prisma.LabCaseWhereInput = {
      AND: [
        this.visibleStartedToLabWhere(labOrganizationId),
        ...(query.sentFrom || query.sentTo
          ? [
              {
                OR: [
                  { sentAt: sentAtFilter },
                  {
                    origin: LabCaseOrigin.LAB_INTERNAL,
                    startedAt: sentAtFilter,
                  },
                ],
              } satisfies Prisma.LabCaseWhereInput,
            ]
          : []),
        ...(query.clinicOrganizationId
          ? [
              {
                OR: [
                  { treatment: { organizationId: query.clinicOrganizationId } },
                  { partnerClinicOrganizationId: query.clinicOrganizationId },
                ],
              } satisfies Prisma.LabCaseWhereInput,
            ]
          : []),
        ...(query.origin ? [{ origin: query.origin } satisfies Prisma.LabCaseWhereInput] : []),
        ...(query.q?.trim() ? [this.buildTaskSearchWhere(query.q.trim())] : []),
      ],
    };

    const base: Prisma.LabCaseTaskWhereInput = {
      ...(query.labCaseId ? { labCaseId: query.labCaseId } : {}),
      labCase: {
        ...labCaseScope,
        ...(query.overdue
          ? {
              dueDate: { not: null, lt: startOfUtcDay() },
              tasks: { some: { status: LabTaskStatus.IN_PROGRESS } },
            }
          : {}),
      },
      ...(status !== undefined ? { status } : {}),
      ...(query.assignedToMe ? { assigneeUserId: actorUserId } : {}),
      ...(query.unassignedOnly ? { assigneeUserId: null } : {}),
      ...(query.prosthesisTypeCode?.trim()
        ? prosthesisTypeTaskWhere(query.prosthesisTypeCode.trim())
        : {}),
    };

    const stepCompleted = query.stepCompleted?.trim();
    if (!stepCompleted) {
      return base;
    }

    const completedGroups = await this.prisma.labCaseTask.groupBy({
      by: ['labCaseId', 'sourceKey', 'tooth', 'prosthesisTypeCode'],
      where: {
        workflowStepCode: stepCompleted,
        status: LabTaskStatus.COMPLETED,
        labCase: labCaseScope,
      },
    });

    if (completedGroups.length === 0) {
      return { id: { in: [] } };
    }

    return {
      AND: [
        base,
        {
          OR: completedGroups.map((group) => ({
            labCaseId: group.labCaseId,
            sourceKey: group.sourceKey,
            tooth: group.tooth,
            prosthesisTypeCode: group.prosthesisTypeCode,
          })),
        },
      ],
    };
  }

  private toListQueryFromLocate(query: LocateTaskPageDto): ListLabTasksDto {
    return {
      page: 1,
      q: query.q,
      clinicOrganizationId: query.clinicOrganizationId,
      status: query.status,
      completed: query.completed,
      important: query.important,
      pinImportant: query.pinImportant,
      sentFrom: query.sentFrom,
      sentTo: query.sentTo,
      stepCompleted: query.stepCompleted,
      assignedToMe: query.assignedToMe,
      overdue: query.overdue,
      unassignedOnly: query.unassignedOnly,
      prosthesisTypeCode: query.prosthesisTypeCode,
      origin: query.origin,
      sortBy: query.sortBy,
      sortDir: query.sortDir,
      limit: query.limit,
    };
  }

  private async countTasksBeforeSortedPosition(
    where: Prisma.LabCaseTaskWhereInput,
    query: ListLabTasksDto,
    target: {
      sentAt: Date;
      labCaseId: string;
      sourceKey: string;
      tooth: string;
      prosthesisTypeCode: string;
      stepOrder: number;
      id: string;
    },
  ): Promise<number> {
    const sortBy = query.sortBy ?? 'date';
    const dir = query.sortDir ?? 'desc';

    if (sortBy !== 'date') {
      throw new AppException(ErrorCode.TASK_PAGE_DATE_SORT_ONLY, HttpStatus.BAD_REQUEST);
    }

    const sentAt = target.sentAt;
    const sameSentAt = {
      labCase: {
        OR: [{ sentAt }, { AND: [{ sentAt: null }, { startedAt: sentAt }] }],
      },
    };
    const tupleBefore: Prisma.LabCaseTaskWhereInput[] = [
      {
        AND: [sameSentAt, { labCaseId: { lt: target.labCaseId } }],
      },
      {
        AND: [
          sameSentAt,
          { labCaseId: target.labCaseId },
          { sourceKey: { lt: target.sourceKey } },
        ],
      },
      {
        AND: [
          sameSentAt,
          { labCaseId: target.labCaseId },
          { sourceKey: target.sourceKey },
          { tooth: { lt: target.tooth } },
        ],
      },
      {
        AND: [
          sameSentAt,
          { labCaseId: target.labCaseId },
          { sourceKey: target.sourceKey },
          { tooth: target.tooth },
          { prosthesisTypeCode: { lt: target.prosthesisTypeCode } },
        ],
      },
      {
        AND: [
          sameSentAt,
          { labCaseId: target.labCaseId },
          { sourceKey: target.sourceKey },
          { tooth: target.tooth },
          { prosthesisTypeCode: target.prosthesisTypeCode },
          { stepOrder: { lt: target.stepOrder } },
        ],
      },
      {
        AND: [
          sameSentAt,
          { labCaseId: target.labCaseId },
          { sourceKey: target.sourceKey },
          { tooth: target.tooth },
          { prosthesisTypeCode: target.prosthesisTypeCode },
          { stepOrder: target.stepOrder },
          { id: { lt: target.id } },
        ],
      },
    ];

    const sentAtBefore: Prisma.LabCaseTaskWhereInput =
      dir === 'desc'
        ? {
            labCase: {
              OR: [{ sentAt: { gt: sentAt } }, { startedAt: { gt: sentAt } }],
            },
          }
        : {
            labCase: {
              OR: [{ sentAt: { lt: sentAt } }, { startedAt: { lt: sentAt } }],
            },
          };

    return this.prisma.labCaseTask.count({
      where: {
        AND: [where, { OR: [sentAtBefore, ...tupleBefore] }],
      },
    });
  }

  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 buildTaskSearchWhere(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 buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] {
    const dir = query.sortDir ?? 'desc';
    const stepTiebreakers: Prisma.LabCaseTaskOrderByWithRelationInput[] = [
      { stepOrder: 'asc' },
      { id: 'asc' },
    ];

    let orderBy: Prisma.LabCaseTaskOrderByWithRelationInput[];

    switch (query.sortBy) {
      case 'status':
        orderBy = [{ status: dir }, { createdAt: 'desc' }, ...stepTiebreakers];
        break;
      case 'clinic':
        orderBy = [
          { labCase: { referringClinicName: dir } },
          { labCase: { treatment: { organization: { name: dir } } } },
          { createdAt: 'desc' },
          ...stepTiebreakers,
        ];
        break;
      case 'patient':
        orderBy = [
          { labCase: { patientDisplayName: dir } },
          { labCase: { treatment: { patient: { lastName: dir } } } },
          { labCase: { treatment: { patient: { firstName: dir } } } },
          ...stepTiebreakers,
        ];
        break;
      case 'important':
        orderBy = [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, ...stepTiebreakers];
        break;
      case 'prosthesis':
        orderBy = [
          { prosthesisTypeCode: dir },
          { createdAt: 'desc' },
          ...stepTiebreakers,
        ];
        break;
      case 'taskType':
        orderBy = [
          { workflowStepCode: dir },
          { stepOrder: 'asc' },
          { createdAt: 'desc' },
          ...stepTiebreakers,
        ];
        break;
      case 'dueDate':
        orderBy = [
          { labCase: { dueDate: dir } },
          { labCase: { startedAt: 'desc' } },
          { labCase: { sentAt: 'desc' } },
          { labCaseId: 'asc' },
          { sourceKey: 'asc' },
          { tooth: 'asc' },
          { prosthesisTypeCode: 'asc' },
          { stepOrder: 'asc' },
          { id: 'asc' },
        ];
        break;
      case 'date':
      default:
        orderBy = [
          { labCase: { startedAt: dir } },
          { labCase: { sentAt: dir } },
          { labCaseId: 'asc' },
          { sourceKey: 'asc' },
          { tooth: 'asc' },
          { prosthesisTypeCode: 'asc' },
          { stepOrder: 'asc' },
          { id: 'asc' },
        ];
        break;
    }

    if (query.pinImportant) {
      return [{ labCase: { isImportant: 'desc' } }, ...orderBy];
    }

    return orderBy;
  }

  private mapTaskListItem(
    task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
    prosthesisLabels: Map<string, string>,
  ) {
    const clinic =
      task.labCase.treatment?.organization ??
      task.labCase.partnerClinic ?? {
        id: '',
        name: task.labCase.referringClinicName?.trim() || '',
      };
    const displayName = task.labCase.patientDisplayName?.trim() || '';
    const [firstName, ...rest] = displayName.split(/\s+/);
    const patient = task.labCase.treatment?.patient ?? {
      id: '',
      firstName: firstName || displayName || clinic.name || '—',
      lastName: rest.join(' '),
    };

    return {
      id: task.id,
      labCaseId: task.labCaseId,
      treatmentDetailId: task.treatmentDetailId ?? task.sourceKey,
      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,
      isImportant: task.labCase.isImportant,
      caseDueDate: task.labCase.dueDate?.toISOString() ?? null,
      isCaseOverdue: isLabCaseOverdue(task.labCase.dueDate, task.labCase.tasks),
      assignee: task.assignee
        ? { id: task.assignee.id, name: task.assignee.name }
        : null,
      assignedAt: task.assignedAt?.toISOString() ?? null,
      lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
      lastStatusChangedBy: task.lastStatusChangedBy
        ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
        : null,
      createdAt: task.createdAt.toISOString(),
      caseSentAt: (task.labCase.sentAt ?? task.labCase.startedAt)?.toISOString() ?? null,
      origin: task.labCase.origin,
      clinic,
      patient,
    };
  }

  private async assertCanReadTasks(userId: string, organizationId: string) {
    const m = await this.getMembership(userId, organizationId);
    if (!m) {
      throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
    }
    if (
      hasEffectivePermission(m, 'TAB_TASKS_READ') ||
      hasEffectivePermission(m, 'TAB_TASKS_EDIT')
    ) {
      return;
    }
    throw new AppException(ErrorCode.PERMISSION_ACCESS_TASKS, HttpStatus.FORBIDDEN);
  }

  private async assertCanEditTasks(userId: string, organizationId: string) {
    const m = await this.getMembership(userId, organizationId);
    if (!m) {
      throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
    }
    if (hasEffectivePermission(m, 'TAB_TASKS_EDIT')) {
      return;
    }
    throw new AppException(ErrorCode.PERMISSION_EDIT_TASKS, HttpStatus.FORBIDDEN);
  }

  private async getMembership(userId: string, organizationId: string) {
    return this.prisma.membership.findFirst({
      where: {
        userId,
        organizationId,
        OR: [{ isOwner: true }, { isActive: true }],
      },
      include: {
        permissions: { include: { permission: true } },
        organization: { include: { type: true, plan: true } },
      },
    });
  }
}
