import {
  ForbiddenException,
  HttpStatus,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository, type SelectQueryBuilder } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { Project } from '../project/entities/project.entity';
import { ProjectMember } from '../project/entities/project-member.entity';
import { Task, type TaskStatus } from './entities/task.entity';
import {
  ChecklistItem,
  type ChecklistCategory,
  type ChecklistWorkStatus,
} from './entities/checklist-item.entity';
import { TaskCheckpoint } from './entities/task-checkpoint.entity';
import {
  CheckpointReport,
  type ReportItemSnapshot,
} from './entities/checkpoint-report.entity';
import { Comment } from './entities/comment.entity';
import { Attachment, type AttachmentKind } from './entities/attachment.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { CheckpointReportDto } from './dto/checkpoint-report.dto';
import {
  ActivityService,
  type ActivityResponse,
} from '../activity/activity.service';
import { NotificationService } from '../notification/notification.service';
import {
  paginated,
  resolvePage,
  type PaginatedResult,
} from '../../common/pagination/pagination';
import {
  UPLOAD_DIR,
  decodeOriginalName,
  deriveKind,
  type UploadedDiskFile,
} from './upload.config';
import { join } from 'path';
import { unlink } from 'fs/promises';

// 목록 행 응답 — 라벨류는 프론트가 파생(dueLabel/dday/statusLabel 등)
export interface ProjectTaskResponse {
  id: number; // 프로젝트 내 순번(seq)
  title: string;
  status: TaskStatus;
  dueDate: string | null;
  checklist: [number, number]; // [완료, 전체]
  commentCount: number;
  fileCount: number; // 첨부 파일 수
  assignees: PublicUser[];
}

// 체크리스트 항목 응답
export interface ChecklistItemResponse {
  id: string;
  text: string;
  done: boolean;
  category: ChecklistCategory;
  // 보완 내용(수정 요청 시 항목별 입력) — 없으면 null
  reviewNote: string | null;
  // 작업자 진행 상태(자가보고) — 미착수/진행중/완료
  workStatus: ChecklistWorkStatus;
}

// 중간 점검일 응답
export interface CheckpointResponse {
  id: string;
  checkAt: string;
}

// 중간 점검 보고 응답(스냅샷 포함)
export interface CheckpointReportResponse {
  id: string;
  checkpointId: string;
  checkAt: string | null;
  reporter: PublicUser | null;
  note: string | null;
  items: ReportItemSnapshot[];
  createdAt: string;
}

// 프로젝트 업무 목록의 세그먼트별 카운트(전체/진행/대기/완료) — 검색어 반영
export interface TaskSegmentCounts {
  all: number;
  prog: number; // 진행 중(prog+review)
  todo: number;
  done: number;
}

// 프로젝트 업무 목록 응답 — 페이지 + 세그먼트 카운트
export type ProjectTaskListResult = PaginatedResult<ProjectTaskResponse> & {
  counts: TaskSegmentCounts;
};

// 첨부파일 응답
export interface AttachmentResponse {
  id: string;
  name: string;
  size: number; // 바이트(표시 변환은 프론트)
  kind: AttachmentKind;
  url: string; // 다운로드 경로
  uploader: PublicUser | null;
  createdAt: string;
}

// 답글 응답
export interface CommentReplyResponse {
  id: string;
  author: PublicUser | null;
  text: string;
  createdAt: string;
}

// 댓글 응답(답글 포함)
export interface CommentResponse {
  id: string;
  author: PublicUser | null;
  text: string;
  file: AttachmentResponse | null;
  createdAt: string;
  replies: CommentReplyResponse[];
}

// 내 업무 행(프로젝트 횡단) — 라벨/그룹핑은 프론트가 파생
export interface MyTaskRowResponse {
  id: number; // 프로젝트 내 순번(seq)
  projectId: string; // projectId (라우팅용)
  projectName: string;
  title: string;
  status: TaskStatus;
  dueDate: string | null;
  checklist: [number, number]; // [완료, 전체]
  assignees: PublicUser[];
  issuer: PublicUser | null;
}

// 승인 요청 정보(승인 대기 상태에서 노출)
export interface ApprovalInfo {
  requester: PublicUser | null;
  requestedAt: string | null;
  note: string | null;
}

// 업무 상세 응답 — activities 는 6단계 영역(현재 빈 배열)
export interface TaskDetailResponse {
  id: number;
  projectId: string; // projectId
  projectName: string;
  title: string;
  status: TaskStatus;
  content: string[];
  checklist: ChecklistItemResponse[];
  assignees: PublicUser[];
  issuer: PublicUser | null;
  dueDate: string | null;
  createdAt: string;
  comments: CommentResponse[];
  activities: ActivityResponse[];
  files: AttachmentResponse[];
  approval: ApprovalInfo | null;
  changesNote: string | null;
  // 중간 점검일 + 작업자 보고 이력
  checkpoints: CheckpointResponse[];
  checkpointReports: CheckpointReportResponse[];
}

// 상태 머신 전이 규칙(허용 목적지)
const TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
  todo: ['prog'],
  prog: ['review'],
  review: ['done', 'changes'],
  changes: ['review', 'prog'],
  done: [],
};

// 상태 한국어 라벨(에러 문구용)
const STATUS_LABEL: Record<TaskStatus, string> = {
  todo: '할 일',
  prog: '진행 중',
  review: '승인 대기',
  done: '완료',
  changes: '수정 요청',
};

// 업무 비즈니스 로직
@Injectable()
export class TaskService {
  constructor(
    @InjectRepository(Task)
    private readonly taskRepo: Repository<Task>,
    @InjectRepository(ChecklistItem)
    private readonly checklistRepo: Repository<ChecklistItem>,
    @InjectRepository(Project)
    private readonly projectRepo: Repository<Project>,
    @InjectRepository(ProjectMember)
    private readonly memberRepo: Repository<ProjectMember>,
    @InjectRepository(Comment)
    private readonly commentRepo: Repository<Comment>,
    @InjectRepository(Attachment)
    private readonly attachmentRepo: Repository<Attachment>,
    @InjectRepository(TaskCheckpoint)
    private readonly checkpointRepo: Repository<TaskCheckpoint>,
    @InjectRepository(CheckpointReport)
    private readonly reportRepo: Repository<CheckpointReport>,
    private readonly activity: ActivityService,
    private readonly notification: NotificationService,
  ) {}

  // 알림 payload 공통 조립 — 프론트가 문구/링크(projectId/taskSeq) 구성에 사용
  private taskNotifyPayload(
    project: Project,
    task: Task,
    actorName: string,
  ): Record<string, unknown> {
    return {
      projectId: project.id,
      projectName: project.name,
      taskSeq: task.seq,
      taskTitle: task.title,
      actorName,
    };
  }

  // 업무 목록 — 세그먼트(all/prog/todo/done)·검색·담당자 필터 + 오프셋 페이지네이션 + 세그먼트 카운트
  async list(
    projectId: string,
    filters: { segment?: string; q?: string; assignee?: string; sort?: string },
    page?: number,
    size?: number,
  ): Promise<ProjectTaskListResult> {
    const project = await this.getProjectOrThrow(projectId);
    const pg = resolvePage(page, size);

    const qb = this.taskRepo
      .createQueryBuilder('task')
      .leftJoinAndSelect('task.assignees', 'assignee')
      .leftJoinAndSelect('task.checklist', 'checklist')
      .where('task.project = :projectId', { projectId: project.id });

    // 세그먼트 → 상태 조건 (진행 중 = prog+review, 그 외 단일 상태. 'all'·'changes 포함'은 무필터)
    const segment = filters.segment ?? 'all';
    if (segment === 'prog') {
      qb.andWhere("task.status IN ('prog', 'review')");
    } else if (segment === 'todo') {
      qb.andWhere("task.status = 'todo'");
    } else if (segment === 'done') {
      qb.andWhere("task.status = 'done'");
    }

    const q = filters.q?.trim();
    if (q) {
      qb.andWhere('task.title ILIKE :q', { q: `%${q}%` });
    }
    // 담당자 필터: 해당 사용자가 담당인 업무만 (서브쿼리로 task id 추림)
    if (filters.assignee) {
      qb.andWhere(
        'task.id IN ' +
          qb
            .subQuery()
            .select('ta.task_id')
            .from('task_assignees', 'ta')
            .where('ta.user_id = :assignee')
            .getQuery(),
        { assignee: filters.assignee },
      );
    }

    // 정렬은 메인 엔티티(task.seq/task.dueDate) 컬럼만 사용한다.
    // skip/take(페이지네이션)와 to-many 조인을 함께 쓰면 TypeORM 이 DISTINCT id 서브쿼리로
    // 페이징하는데, 이때 조인된 to-many 컬럼(checklist.sort_order)으로 ORDER BY 하면
    // 컬럼 메타데이터를 찾지 못해 터진다(createOrderByCombinedWithSelectExpression).
    // 따라서 마감/순번 등 메인 엔티티 컬럼으로만 정렬한다.
    const sort = filters.sort ?? 'latest';
    if (sort === 'due') {
      // 마감 임박순(마감 없음은 뒤로) → 동률은 최신 순번
      qb.orderBy('task.dueDate', 'ASC', 'NULLS LAST').addOrderBy(
        'task.seq',
        'DESC',
      );
    } else if (sort === 'oldest') {
      qb.orderBy('task.seq', 'ASC');
    } else {
      // 최신순(기본)
      qb.orderBy('task.seq', 'DESC');
    }

    const [tasks, total] = await qb
      .skip(pg.skip)
      .take(pg.take)
      .getManyAndCount();

    const taskIds = tasks.map((t) => t.id);
    const [commentCounts, fileCounts] = await Promise.all([
      this.commentCountsByTask(taskIds),
      this.fileCountsByTask(taskIds),
    ]);
    const items = tasks.map((t) =>
      this.toListResponse(
        t,
        commentCounts.get(t.id) ?? 0,
        fileCounts.get(t.id) ?? 0,
      ),
    );

    const counts = await this.segmentCounts(project.id, q);
    return { ...paginated(items, total, pg), counts };
  }

  // 세그먼트별 카운트(검색어 반영) — ProjectDetailPage 세그먼트 배지용
  private async segmentCounts(
    projectId: string,
    q?: string,
  ): Promise<TaskSegmentCounts> {
    const cqb = this.taskRepo
      .createQueryBuilder('task')
      .where('task.project = :projectId', { projectId });
    if (q) {
      cqb.andWhere('task.title ILIKE :q', { q: `%${q}%` });
    }
    const rows = await cqb
      .select('task.status', 'status')
      .addSelect('COUNT(*)', 'count')
      .groupBy('task.status')
      .getRawMany<{ status: TaskStatus; count: string }>();

    const counts: TaskSegmentCounts = { all: 0, prog: 0, todo: 0, done: 0 };
    for (const r of rows) {
      const c = Number(r.count);
      counts.all += c;
      if (r.status === 'prog' || r.status === 'review') counts.prog += c;
      else if (r.status === 'todo') counts.todo += c;
      else if (r.status === 'done') counts.done += c;
      // 'changes'(수정 요청)는 전체에만 집계(기존 ProjectDetailPage 세그먼트 동작과 동일)
    }
    return counts;
  }

  // 업무 단건 상세 — project 내 순번(seq)으로 조회
  async findOne(projectId: string, seq: number): Promise<TaskDetailResponse> {
    const project = await this.getProjectOrThrow(projectId);
    const task = await this.getTaskOrThrow(project.id, seq);
    return this.buildDetail(task, project);
  }

  // 업무 생성 — admin(지시자) 권한. 담당자는 프로젝트 멤버만 지정 가능
  async create(
    projectId: string,
    actingUserId: string,
    dto: CreateTaskDto,
  ): Promise<TaskDetailResponse> {
    const project = await this.getProjectOrThrow(projectId);
    const issuer = await this.assertAdmin(project.id, actingUserId);

    const assignees = await this.resolveMemberAssignees(
      project.id,
      dto.assigneeIds,
    );

    // 프로젝트 내 다음 순번 채번
    const last = await this.taskRepo.findOne({
      where: { project: { id: project.id } },
      order: { seq: 'DESC' },
    });
    const seq = (last?.seq ?? 0) + 1;

    const task = this.taskRepo.create({
      seq,
      project,
      title: dto.title,
      content: dto.content ?? [],
      status: 'todo',
      dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
      issuer: issuer.user,
      assignees,
      checklist: (dto.checklist ?? []).map((c, idx) =>
        this.checklistRepo.create({
          text: c.text,
          category: c.category ?? '필수',
          done: false,
          sortOrder: idx,
        }),
      ),
    });
    const saved = await this.taskRepo.save(task);

    // 중간 점검일 생성(있으면)
    await this.syncCheckpoints(saved, dto.checkpoints);

    // 활동 적재 — 업무 생성
    await this.activity.record({
      projectId: project.id,
      actorId: actingUserId,
      type: 'task.created',
      payload: { taskTitle: saved.title },
      taskSeq: saved.seq,
    });

    // 알림 — 담당자들에게 '업무 지시'(행위자=지시자는 자동 제외)
    await this.notification.notify({
      recipientIds: assignees.map((a) => a.id),
      actorId: actingUserId,
      type: 'task.assigned',
      payload: this.taskNotifyPayload(project, saved, issuer.user.name),
    });

    return this.buildDetail(
      await this.getTaskOrThrow(project.id, saved.seq),
      project,
    );
  }

  // 업무 수정 — admin 권한. 제공된 필드만 갱신
  async update(
    projectId: string,
    actingUserId: string,
    seq: number,
    dto: UpdateTaskDto,
  ): Promise<TaskDetailResponse> {
    const project = await this.getProjectOrThrow(projectId);
    const acting = await this.assertAdmin(project.id, actingUserId);
    const task = await this.getTaskOrThrow(project.id, seq);

    // 변경 감지용 이전 상태 캡처
    const prevTitle = task.title;
    const prevContent = task.content ?? [];
    const prevDueIso = task.dueDate ? task.dueDate.toISOString() : null;
    const prevAssigneeIds = (task.assignees ?? []).map((a) => a.id).sort();

    if (dto.title !== undefined) task.title = dto.title;
    if (dto.content !== undefined) task.content = dto.content;
    if (dto.dueDate !== undefined) {
      task.dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
    }
    if (dto.assigneeIds !== undefined) {
      task.assignees = await this.resolveMemberAssignees(
        project.id,
        dto.assigneeIds,
      );
    }

    await this.taskRepo.save(task);

    // 체크리스트 항목 동기화(추가/삭제/내용·순서 갱신) — 제공된 경우에만
    let checklistChanged = false;
    if (dto.checklist !== undefined) {
      checklistChanged = await this.syncChecklist(task, dto.checklist);
    }

    // 중간 점검일 동기화(제공된 경우에만) — 날짜 기준 추가/삭제(미변경 항목·보고 유지)
    if (dto.checkpoints !== undefined) {
      await this.syncCheckpoints(task, dto.checkpoints);
    }

    // 변경 항목별 활동 적재(담당자/마감/일반 수정)
    await this.recordUpdateActivities(project, actingUserId, task, {
      dto,
      prevTitle,
      prevContent,
      prevDueIso,
      prevAssigneeIds,
      checklistChanged,
    });

    // 알림 — 담당자 변경(추가분=지시, 제거분=담당 해제)
    if (dto.assigneeIds !== undefined) {
      const newIds = (task.assignees ?? []).map((a) => a.id);
      const added = newIds.filter((id) => !prevAssigneeIds.includes(id));
      const removed = prevAssigneeIds.filter((id) => !newIds.includes(id));
      if (added.length) {
        await this.notification.notify({
          recipientIds: added,
          actorId: actingUserId,
          type: 'task.assigned',
          payload: this.taskNotifyPayload(project, task, acting.user.name),
        });
      }
      if (removed.length) {
        await this.notification.notify({
          recipientIds: removed,
          actorId: actingUserId,
          type: 'task.unassigned',
          payload: this.taskNotifyPayload(project, task, acting.user.name),
        });
      }
    }

    // 알림 — 마감 변경 시 현재 담당자에게(추가/해제와 별개로 발송)
    if (dto.dueDate !== undefined) {
      const newDueIso = task.dueDate ? task.dueDate.toISOString() : null;
      if (newDueIso !== prevDueIso) {
        await this.notification.notify({
          recipientIds: (task.assignees ?? []).map((a) => a.id),
          actorId: actingUserId,
          type: 'task.due_changed',
          payload: {
            ...this.taskNotifyPayload(project, task, acting.user.name),
            dueDate: newDueIso,
          },
        });
      }
    }

    return this.buildDetail(
      await this.getTaskOrThrow(project.id, seq),
      project,
    );
  }

  // 업무 수정 시 실제로 바뀐 측면만 골라 활동 적재(담당자/마감/일반)
  private async recordUpdateActivities(
    project: Project,
    actorId: string,
    task: Task,
    prev: {
      dto: UpdateTaskDto;
      prevTitle: string;
      prevContent: string[];
      prevDueIso: string | null;
      prevAssigneeIds: string[];
      checklistChanged: boolean;
    },
  ): Promise<void> {
    const { dto } = prev;

    // 담당자 변경
    if (dto.assigneeIds !== undefined) {
      const newIds = (task.assignees ?? []).map((a) => a.id).sort();
      if (JSON.stringify(newIds) !== JSON.stringify(prev.prevAssigneeIds)) {
        await this.activity.record({
          projectId: project.id,
          actorId,
          type: 'task.assignees_changed',
          payload: {
            taskTitle: task.title,
            assigneeNames: (task.assignees ?? []).map((a) => a.name),
          },
          taskSeq: task.seq,
        });
      }
    }

    // 마감 변경
    if (dto.dueDate !== undefined) {
      const newDueIso = task.dueDate ? task.dueDate.toISOString() : null;
      if (newDueIso !== prev.prevDueIso) {
        await this.activity.record({
          projectId: project.id,
          actorId,
          type: 'task.due_changed',
          payload: { taskTitle: task.title, dueDate: newDueIso },
          taskSeq: task.seq,
        });
      }
    }

    // 일반 수정(제목/내용/체크리스트)
    const titleChanged =
      dto.title !== undefined && dto.title !== prev.prevTitle;
    const contentChanged =
      dto.content !== undefined &&
      JSON.stringify(dto.content) !== JSON.stringify(prev.prevContent);
    if (titleChanged || contentChanged || prev.checklistChanged) {
      await this.activity.record({
        projectId: project.id,
        actorId,
        type: 'task.edited',
        payload: { taskTitle: task.title },
        taskSeq: task.seq,
      });
    }
  }

  // 체크리스트 전체 동기화 — id 기준 머지(기존 유지/완료 보존, 신규 추가, 빠진 항목 삭제)
  // 반환: 의미 있는 변경(추가/삭제/내용변경)이 있었는지 — 활동 적재 판단용(순서만 변경은 제외)
  private async syncChecklist(
    task: Task,
    items: { id?: string; text: string; category?: ChecklistCategory }[],
  ): Promise<boolean> {
    const existing = task.checklist ?? [];
    const byId = new Map(existing.map((c) => [c.id, c]));
    const keepIds = new Set<string>();
    const toSave: ChecklistItem[] = [];
    let changed = false;

    items.forEach((it, idx) => {
      const category = it.category ?? '필수';
      const found = it.id ? byId.get(it.id) : undefined;
      if (found) {
        // 기존 항목 — 내용/카테고리/순서만 갱신(완료 상태 done 은 보존)
        if (found.text !== it.text || found.category !== category) {
          changed = true; // 변경 감지(갱신 전 비교)
        }
        found.text = it.text;
        found.category = category;
        found.sortOrder = idx;
        keepIds.add(found.id);
        toSave.push(found);
      } else {
        // 신규 항목 — 완료는 항상 false 로 생성
        changed = true;
        toSave.push(
          this.checklistRepo.create({
            text: it.text,
            category,
            done: false,
            sortOrder: idx,
            task,
          }),
        );
      }
    });

    const toRemove = existing.filter((c) => !keepIds.has(c.id));
    if (toRemove.length) {
      changed = true;
      await this.checklistRepo.remove(toRemove);
    }
    if (toSave.length) await this.checklistRepo.save(toSave);
    return changed;
  }

  // 업무 삭제 — admin 권한
  async remove(
    projectId: string,
    actingUserId: string,
    seq: number,
  ): Promise<void> {
    const project = await this.getProjectOrThrow(projectId);
    const acting = await this.assertAdmin(project.id, actingUserId);
    const task = await this.getTaskOrThrow(project.id, seq);
    // 디스크 첨부 파일 정리(베스트 에포트) — DB 행은 FK CASCADE 로 함께 삭제됨
    const files = await this.attachmentRepo.find({
      where: { task: { id: task.id } },
    });
    await Promise.all(files.map((f) => this.deleteFileFromDisk(f.storedName)));
    const taskTitle = task.title; // 삭제 전 캡처
    const taskSeq = task.seq;
    // 삭제 알림 수신자(지시자 + 담당자)와 payload 를 삭제 전에 스냅샷
    const removalRecipients = [
      ...(task.issuer ? [task.issuer.id] : []),
      ...(task.assignees ?? []).map((a) => a.id),
    ];
    const removalPayload = this.taskNotifyPayload(
      project,
      task,
      acting.user.name,
    );
    await this.taskRepo.remove(task);

    // 활동 적재 — 업무 삭제(활동은 taskSeq 로만 연결돼 업무 삭제 후에도 유지)
    await this.activity.record({
      projectId: project.id,
      actorId: actingUserId,
      type: 'task.removed',
      payload: { taskTitle },
      taskSeq,
    });

    // 알림 — 지시자/담당자에게 '업무 삭제'(업무가 사라지므로 프론트는 프로젝트로 이동)
    await this.notification.notify({
      recipientIds: removalRecipients,
      actorId: actingUserId,
      type: 'task.removed',
      payload: removalPayload,
    });
  }

  // 상태 변경 — 상태 머신 전이 규칙 + 역할 검증
  async changeStatus(
    projectId: string,
    actingUserId: string,
    seq: number,
    next: TaskStatus,
  ): Promise<TaskDetailResponse> {
    const project = await this.getProjectOrThrow(projectId);
    const membership = await this.assertMember(project.id, actingUserId);
    const task = await this.getTaskOrThrow(project.id, seq);

    await this.transition(project, task, membership, actingUserId, next);

    return this.buildDetail(
      await this.getTaskOrThrow(project.id, seq),
      project,
    );
  }

  // --- 5단계: 댓글 / 답글 ---

  // 댓글 작성 — 프로젝트 멤버. fileId 로 기존 첨부 연결 가능
  async addComment(
    projectId: string,
    actingUserId: string,
    seq: number,
    text: string,
    fileId?: string,
  ): Promise<CommentResponse> {
    const project = await this.getProjectOrThrow(projectId);
    const membership = await this.assertMember(project.id, actingUserId);
    const task = await this.getTaskOrThrow(project.id, seq);

    const attachment = fileId
      ? await this.getAttachmentOrThrow(task.id, fileId)
      : null;

    const comment = this.commentRepo.create({
      task,
      author: membership.user,
      text,
      attachment,
      parent: null,
    });
    const saved = await this.commentRepo.save(comment);

    // 활동 적재 — 댓글 작성
    await this.activity.record({
      projectId: project.id,
      actorId: actingUserId,
      type: 'task.comment_added',
      payload: { taskTitle: task.title },
      taskSeq: task.seq,
    });

    // 알림 — 업무 관련자(지시자 + 담당자, 작성자 본인 제외)에게 '댓글'
    await this.notification.notify({
      recipientIds: [
        ...(task.issuer ? [task.issuer.id] : []),
        ...task.assignees.map((a) => a.id),
      ],
      actorId: actingUserId,
      type: 'task.commented',
      payload: this.taskNotifyPayload(project, task, membership.user.name),
    });

    return this.toCommentResponse(
      await this.commentRepo.findOneOrFail({
        where: { id: saved.id },
        relations: ['author', 'attachment', 'replies', 'replies.author'],
      }),
      project,
      task.seq,
    );
  }

  // 답글 작성 — 프로젝트 멤버. 최상위 댓글에만 답글 가능(1단계 깊이)
  async addReply(
    projectId: string,
    actingUserId: string,
    seq: number,
    commentId: string,
    text: string,
  ): Promise<CommentReplyResponse> {
    const project = await this.getProjectOrThrow(projectId);
    const membership = await this.assertMember(project.id, actingUserId);
    const task = await this.getTaskOrThrow(project.id, seq);

    const parent = await this.commentRepo.findOne({
      where: { id: commentId, task: { id: task.id }, parent: IsNull() },
      relations: ['author'], // 답글 알림 수신자(원댓글 작성자) 식별용
    });
    if (!parent) {
      throw new NotFoundException();
    }

    const reply = this.commentRepo.create({
      task,
      author: membership.user,
      text,
      parent,
    });
    const saved = await this.commentRepo.save(reply);

    // 활동 적재 — 답글도 댓글과 동일 타입으로 기록
    await this.activity.record({
      projectId: project.id,
      actorId: actingUserId,
      type: 'task.comment_added',
      payload: { taskTitle: task.title },
      taskSeq: task.seq,
    });

    // 알림 — 업무 관련자(지시자 + 담당자) + 원댓글 작성자(비참여자여도), 작성자 본인 제외
    await this.notification.notify({
      recipientIds: [
        ...(task.issuer ? [task.issuer.id] : []),
        ...task.assignees.map((a) => a.id),
        ...(parent.author ? [parent.author.id] : []),
      ],
      actorId: actingUserId,
      type: 'task.commented',
      payload: {
        ...this.taskNotifyPayload(project, task, membership.user.name),
        isReply: true,
      },
    });

    return {
      id: saved.id,
      author: membership.user ? UserService.toPublic(membership.user) : null,
      text: saved.text,
      createdAt: saved.createdAt.toISOString(),
    };
  }

  // --- 5단계: 파일 첨부 ---

  // 파일 업로드 — 프로젝트 멤버. 디스크 저장은 multer 가 처리, 메타데이터만 영속
  async addFile(
    projectId: string,
    actingUserId: string,
    seq: number,
    file: UploadedDiskFile,
  ): Promise<AttachmentResponse> {
    const project = await this.getProjectOrThrow(projectId);
    const membership = await this.assertMember(project.id, actingUserId);
    const task = await this.getTaskOrThrow(project.id, seq);

    const name = decodeOriginalName(file.originalname);
    const attachment = this.attachmentRepo.create({
      task,
      uploader: membership.user,
      name,
      storedName: file.filename,
      size: file.size,
      mime: file.mimetype,
      kind: deriveKind(file.mimetype, name),
    });
    const saved = await this.attachmentRepo.save(attachment);

    // 활동 적재 — 파일 첨부
    await this.activity.record({
      projectId: project.id,
      actorId: actingUserId,
      type: 'task.file_attached',
      payload: { fileName: name, taskTitle: task.title },
      taskSeq: task.seq,
    });

    return this.toAttachmentResponse(
      { ...saved, uploader: membership.user },
      project,
      task.seq,
    );
  }

  // 파일 삭제 — 프로젝트 멤버. 디스크 파일도 정리
  async removeFile(
    projectId: string,
    actingUserId: string,
    seq: number,
    fileId: string,
  ): Promise<void> {
    const project = await this.getProjectOrThrow(projectId);
    const membership = await this.assertMember(project.id, actingUserId);
    const task = await this.getTaskOrThrow(project.id, seq);
    const attachment = await this.getAttachmentOrThrow(task.id, fileId);
    // 업로더 본인 또는 관리자만 삭제 가능 — 멤버라도 타인의 첨부는 지울 수 없음
    if (
      attachment.uploader?.id !== actingUserId &&
      membership.roleType !== 'admin'
    ) {
      throw new ForbiddenException();
    }
    const fileName = attachment.name; // 삭제 전 캡처
    await this.attachmentRepo.remove(attachment);
    await this.deleteFileFromDisk(attachment.storedName);

    // 활동 적재 — 첨부 삭제
    await this.activity.record({
      projectId: project.id,
      actorId: actingUserId,
      type: 'task.file_removed',
      payload: { fileName, taskTitle: task.title },
      taskSeq: task.seq,
    });
  }

  // 다운로드용 첨부 메타 + 디스크 절대경로 — 컨트롤러가 스트리밍
  async getFileForDownload(
    projectId: string,
    actingUserId: string,
    seq: number,
    fileId: string,
  ): Promise<{ attachment: Attachment; absolutePath: string }> {
    const project = await this.getProjectOrThrow(projectId);
    await this.assertMember(project.id, actingUserId);
    const task = await this.getTaskOrThrow(project.id, seq);
    const attachment = await this.getAttachmentOrThrow(task.id, fileId);
    return {
      attachment,
      absolutePath: join(UPLOAD_DIR, attachment.storedName),
    };
  }

  // --- 5단계: 승인 워크플로우 ---

  // 승인 요청(담당자 → 지시자): prog/changes → review. 요청 메시지 보관
  async requestApproval(
    projectId: string,
    actingUserId: string,
    seq: number,
    note?: string,
  ): Promise<TaskDetailResponse> {
    const project = await this.getProjectOrThrow(projectId);
    const membership = await this.assertMember(project.id, actingUserId);
    const task = await this.getTaskOrThrow(project.id, seq);

    task.approvalNote = note?.trim() ? note.trim() : null;
    task.approvalRequestedBy = membership.user;
    task.approvalRequestedAt = new Date();
    task.changesNote = null;

    await this.transition(project, task, membership, actingUserId, 'review');
    return this.buildDetail(
      await this.getTaskOrThrow(project.id, seq),
      project,
    );
  }

  // 승인(지시자/admin): review → done
  async approve(
    projectId: string,
    actingUserId: string,
    seq: number,
  ): Promise<TaskDetailResponse> {
    const project = await this.getProjectOrThrow(projectId);
    const membership = await this.assertMember(project.id, actingUserId);
    const task = await this.getTaskOrThrow(project.id, seq);

    await this.transition(project, task, membership, actingUserId, 'done');
    return this.buildDetail(
      await this.getTaskOrThrow(project.id, seq),
      project,
    );
  }

  // 수정 요청(지시자/admin): review → changes. 보완 메시지 + 항목별 완료 체크 보관
  async requestChanges(
    projectId: string,
    actingUserId: string,
    seq: number,
    note: string | undefined,
    reviews?: { id: string; done: boolean; note?: string }[],
  ): Promise<TaskDetailResponse> {
    const project = await this.getProjectOrThrow(projectId);
    const membership = await this.assertMember(project.id, actingUserId);
    const task = await this.getTaskOrThrow(project.id, seq);

    task.changesNote = note?.trim() ? note.trim() : null;
    task.approvalNote = null;
    task.approvalRequestedAt = null;

    // 항목별 완료 검토 결과 반영(체크=완성 / X=미완성 + 항목별 보완 내용) — 전달된 항목만 갱신
    if (reviews?.length && task.checklist?.length) {
      const reviewMap = new Map(reviews.map((r) => [r.id, r]));
      let checklistChanged = false;
      for (const item of task.checklist) {
        const reviewed = reviewMap.get(item.id);
        if (!reviewed) continue;
        // 완료 처리된 항목은 보완 내용 비움, 미완성 항목만 보완 내용 보관
        const nextNote = reviewed.done ? null : reviewed.note?.trim() || null;
        if (item.done !== reviewed.done || item.reviewNote !== nextNote) {
          item.done = reviewed.done;
          item.reviewNote = nextNote;
          checklistChanged = true;
        }
      }
      if (checklistChanged) await this.checklistRepo.save(task.checklist);
    }

    await this.transition(project, task, membership, actingUserId, 'changes');
    return this.buildDetail(
      await this.getTaskOrThrow(project.id, seq),
      project,
    );
  }

  // --- 7단계: 내 업무(프로젝트 횡단 집계) ---

  // 내 업무 공통 필터/정렬 적용 — 검색(q)·프로젝트(projectId)·정렬(sort)
  // 정렬은 메인 엔티티 컬럼만 사용(to-many 조인 + skip/take 제약).
  private applyMyTaskFilters(
    qb: SelectQueryBuilder<Task>,
    filters: { q?: string; projectId?: string; sort?: string },
  ): void {
    const q = filters.q?.trim();
    if (q) {
      qb.andWhere('task.title ILIKE :q', { q: `%${q}%` });
    }
    if (filters.projectId) {
      qb.andWhere('project.id = :pid', { pid: filters.projectId });
    }
    const sort = filters.sort ?? 'due';
    if (sort === 'recent') {
      // 최근 등록순(프로젝트 횡단이라 seq 대신 생성시각 기준)
      qb.orderBy('task.createdAt', 'DESC');
    } else {
      // 마감 임박순(기본, 마감 없음은 뒤로)
      qb.orderBy('task.dueDate', 'ASC', 'NULLS LAST').addOrderBy(
        'task.seq',
        'DESC',
      );
    }
  }

  // 내가 담당인 업무 — 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션
  async listAssigned(
    userId: string,
    page?: number,
    size?: number,
    filters: { q?: string; projectId?: string; sort?: string } = {},
  ): Promise<PaginatedResult<MyTaskRowResponse>> {
    const pg = resolvePage(page, size);
    const qb = this.taskRepo
      .createQueryBuilder('task')
      .leftJoinAndSelect('task.project', 'project')
      .leftJoinAndSelect('task.assignees', 'assignee')
      .leftJoinAndSelect('task.issuer', 'issuer')
      .leftJoinAndSelect('task.checklist', 'checklist')
      .where(
        'task.id IN ' +
          this.taskRepo
            .createQueryBuilder()
            .subQuery()
            .select('ta.task_id')
            .from('task_assignees', 'ta')
            .where('ta.user_id = :userId')
            .getQuery(),
      )
      .setParameter('userId', userId);
    this.applyMyTaskFilters(qb, filters);
    const [tasks, total] = await qb
      .skip(pg.skip)
      .take(pg.take)
      .getManyAndCount();
    return paginated(
      tasks.map((t) => this.toMyTaskRow(t)),
      total,
      pg,
    );
  }

  // 내가 지시(생성)한 업무 — 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션
  async listIssued(
    userId: string,
    page?: number,
    size?: number,
    filters: { q?: string; projectId?: string; sort?: string } = {},
  ): Promise<PaginatedResult<MyTaskRowResponse>> {
    const pg = resolvePage(page, size);
    const qb = this.taskRepo
      .createQueryBuilder('task')
      .leftJoinAndSelect('task.project', 'project')
      .leftJoinAndSelect('task.assignees', 'assignee')
      .leftJoinAndSelect('task.issuer', 'issuer')
      .leftJoinAndSelect('task.checklist', 'checklist')
      .where('task.issuer = :userId', { userId });
    this.applyMyTaskFilters(qb, filters);
    const [tasks, total] = await qb
      .skip(pg.skip)
      .take(pg.take)
      .getManyAndCount();
    return paginated(
      tasks.map((t) => this.toMyTaskRow(t)),
      total,
      pg,
    );
  }

  // 엔티티 → 내 업무 행 응답
  private toMyTaskRow(task: Task): MyTaskRowResponse {
    const checklist = task.checklist ?? [];
    const done = checklist.filter((c) => c.done).length;
    return {
      id: task.seq,
      projectId: task.project.id,
      projectName: task.project.name,
      title: task.title,
      status: task.status,
      dueDate: task.dueDate ? task.dueDate.toISOString() : null,
      checklist: [done, checklist.length],
      assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)),
      issuer: task.issuer ? UserService.toPublic(task.issuer) : null,
    };
  }

  // --- 내부 헬퍼 ---

  // 상태 전이 1건 적용 — 전이 규칙 + 역할 검증 후 저장(부수효과 포함)
  private async transition(
    project: Project,
    task: Task,
    membership: ProjectMember,
    actingUserId: string,
    next: TaskStatus,
  ): Promise<void> {
    const from = task.status;
    if (from === next) {
      return;
    }
    const allowed = TRANSITIONS[from] ?? [];
    if (!allowed.includes(next)) {
      throw new BusinessException(
        'BIZ_001',
        `'${STATUS_LABEL[from]}' 상태에서 '${STATUS_LABEL[next]}'(으)로 변경할 수 없습니다.`,
        HttpStatus.CONFLICT,
      );
    }

    const isAdmin = membership.roleType === 'admin';
    const isIssuer = task.issuer?.id === actingUserId;
    const isAssignee = task.assignees.some((a) => a.id === actingUserId);
    // 승인/수정요청(review 출발)은 지시자(또는 admin), 그 외 작업 전이는 담당자(또는 admin/지시자)
    const permitted =
      from === 'review'
        ? isAdmin || isIssuer
        : isAdmin || isIssuer || isAssignee;
    if (!permitted) {
      throw new ForbiddenException();
    }

    task.status = next;
    await this.taskRepo.save(task);

    // 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리 + 보완 내용 비움(부수효과로 영속)
    if (next === 'done' && task.checklist?.length) {
      for (const item of task.checklist) {
        item.done = true;
        item.reviewNote = null;
      }
      await this.checklistRepo.save(task.checklist);
    }

    // 활동 적재 — 상태 전이(시작/승인요청/승인/수정요청 등은 프론트가 from→to 로 문구 파생)
    await this.activity.record({
      projectId: project.id,
      actorId: actingUserId,
      type: 'task.status_changed',
      payload: { statusFrom: from, statusTo: next, taskTitle: task.title },
      taskSeq: task.seq,
    });

    // 알림 — 전이 종류별 수신자(승인요청→지시자, 승인/수정요청→담당자). 시작(→prog)은 알림 없음
    const actorName = membership.user.name;
    if (next === 'review') {
      await this.notification.notify({
        recipientIds: task.issuer ? [task.issuer.id] : [],
        actorId: actingUserId,
        type: 'task.approval_requested',
        payload: this.taskNotifyPayload(project, task, actorName),
      });
    } else if (next === 'done') {
      await this.notification.notify({
        recipientIds: task.assignees.map((a) => a.id),
        actorId: actingUserId,
        type: 'task.approved',
        payload: this.taskNotifyPayload(project, task, actorName),
      });
    } else if (next === 'changes') {
      await this.notification.notify({
        recipientIds: task.assignees.map((a) => a.id),
        actorId: actingUserId,
        type: 'task.changes_requested',
        payload: this.taskNotifyPayload(project, task, actorName),
      });
    } else if (next === 'prog') {
      // 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자에게
      await this.notification.notify({
        recipientIds: task.issuer ? [task.issuer.id] : [],
        actorId: actingUserId,
        type: 'task.started',
        payload: this.taskNotifyPayload(project, task, actorName),
      });
    }
  }

  // projectId 으로 프로젝트 로딩, 없으면 404
  private async getProjectOrThrow(projectId: string): Promise<Project> {
    const project = await this.projectRepo.findOne({
      where: { id: projectId },
    });
    if (!project) {
      throw new NotFoundException();
    }
    return project;
  }

  // project 내 순번(seq)으로 업무(+관계) 로딩, 없으면 404
  private async getTaskOrThrow(projectId: string, seq: number): Promise<Task> {
    const task = await this.taskRepo.findOne({
      where: { project: { id: projectId }, seq },
      relations: ['assignees', 'issuer', 'checklist', 'approvalRequestedBy'],
    });
    if (!task) {
      throw new NotFoundException();
    }
    // 체크리스트 표시 순서 보정
    task.checklist?.sort((a, b) => a.sortOrder - b.sortOrder);
    return task;
  }

  // 업무에 속한 첨부 로딩, 없으면 404
  private async getAttachmentOrThrow(
    taskId: string,
    fileId: string,
  ): Promise<Attachment> {
    const attachment = await this.attachmentRepo.findOne({
      where: { id: fileId, task: { id: taskId } },
      relations: ['uploader'],
    });
    if (!attachment) {
      throw new NotFoundException();
    }
    return attachment;
  }

  // admin 권한 확인 — 멤버십(+user) 반환(지시자 지정용). 아니면 403
  private async assertAdmin(
    projectId: string,
    userId: string,
  ): Promise<ProjectMember> {
    const membership = await this.memberRepo.findOne({
      where: { project: { id: projectId }, user: { id: userId } },
      relations: ['user'],
    });
    if (!membership || membership.roleType !== 'admin') {
      throw new ForbiddenException();
    }
    return membership;
  }

  // 프로젝트 멤버 확인 — 멤버십(+user) 반환. 아니면 403
  private async assertMember(
    projectId: string,
    userId: string,
  ): Promise<ProjectMember> {
    const membership = await this.memberRepo.findOne({
      where: { project: { id: projectId }, user: { id: userId } },
      relations: ['user'],
    });
    if (!membership) {
      throw new ForbiddenException();
    }
    return membership;
  }

  // 담당자 ID 목록 → User[] 로 변환하며 "프로젝트 멤버" 인지 검증
  private async resolveMemberAssignees(
    projectId: string,
    assigneeIds: string[],
  ): Promise<ProjectMember['user'][]> {
    const uniqueIds = [...new Set(assigneeIds)];
    const memberships = await this.memberRepo.find({
      where: { project: { id: projectId }, user: { id: In(uniqueIds) } },
      relations: ['user'],
    });
    if (memberships.length !== uniqueIds.length) {
      throw new BusinessException(
        'BIZ_001',
        '담당자는 프로젝트 멤버만 지정할 수 있습니다.',
        HttpStatus.BAD_REQUEST,
      );
    }
    return memberships.map((m) => m.user);
  }

  // 업무별 댓글 수(답글 포함) 집계 — 목록 commentCount 용
  private async commentCountsByTask(
    taskIds: string[],
  ): Promise<Map<string, number>> {
    const map = new Map<string, number>();
    if (taskIds.length === 0) return map;
    const rows = await this.commentRepo
      .createQueryBuilder('comment')
      .select('comment.task_id', 'taskId')
      .addSelect('COUNT(*)', 'count')
      .where('comment.task_id IN (:...taskIds)', { taskIds })
      .groupBy('comment.task_id')
      .getRawMany<{ taskId: string; count: string }>();
    for (const r of rows) map.set(r.taskId, Number(r.count));
    return map;
  }

  // 업무별 첨부 파일 수 집계 — 목록 fileCount 용
  private async fileCountsByTask(
    taskIds: string[],
  ): Promise<Map<string, number>> {
    const map = new Map<string, number>();
    if (taskIds.length === 0) return map;
    const rows = await this.attachmentRepo
      .createQueryBuilder('att')
      .select('att.task_id', 'taskId')
      .addSelect('COUNT(*)', 'count')
      .where('att.task_id IN (:...taskIds)', { taskIds })
      .groupBy('att.task_id')
      .getRawMany<{ taskId: string; count: string }>();
    for (const r of rows) map.set(r.taskId, Number(r.count));
    return map;
  }

  // 디스크 파일 삭제(베스트 에포트 — 이미 없으면 무시)
  private async deleteFileFromDisk(storedName: string): Promise<void> {
    try {
      await unlink(join(UPLOAD_DIR, storedName));
    } catch {
      // 파일이 이미 없거나 권한 문제 — 무시(로그성 정리)
    }
  }

  // 엔티티 → 목록 행 응답
  private toListResponse(
    task: Task,
    commentCount: number,
    fileCount: number,
  ): ProjectTaskResponse {
    const checklist = task.checklist ?? [];
    const done = checklist.filter((c) => c.done).length;
    return {
      id: task.seq,
      title: task.title,
      status: task.status,
      dueDate: task.dueDate ? task.dueDate.toISOString() : null,
      checklist: [done, checklist.length],
      commentCount,
      fileCount,
      assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)),
    };
  }

  // --- 중간 점검(체크포인트) ---

  // 날짜를 KST(UTC+9) 기준 'yyyy-MM-dd' 로 — '당일' 판정에 사용
  private kstDate(d: Date): string {
    return new Date(d.getTime() + 9 * 60 * 60 * 1000)
      .toISOString()
      .slice(0, 10);
  }

  // 중간 점검 보고 — 담당자만. '점검일 당일(KST)' 에만 가능. 보고서로 받은 항목 상태를
  // 체크리스트에 반영하고, 그 상태를 스냅샷으로 기록 + 지시자에게 알림.
  async submitCheckpointReport(
    projectId: string,
    actingUserId: string,
    seq: number,
    checkpointId: string,
    dto: CheckpointReportDto,
  ): Promise<TaskDetailResponse> {
    const project = await this.getProjectOrThrow(projectId);
    const task = await this.getTaskOrThrow(project.id, seq);
    this.assertAssignee(task, actingUserId);

    const checkpoint = await this.checkpointRepo.findOne({
      where: { id: checkpointId, task: { id: task.id } },
    });
    if (!checkpoint) {
      throw new NotFoundException();
    }

    // 보고 가능 시점: 점검일 당일에만(지나면 누락 — 보고 불가)
    if (this.kstDate(checkpoint.checkAt) !== this.kstDate(new Date())) {
      throw new BusinessException(
        'BIZ_001',
        '중간 점검 보고는 점검일 당일에만 보낼 수 있습니다.',
        HttpStatus.BAD_REQUEST,
      );
    }

    // 보고서로 받은 상태를 항목에 반영 + 체크리스트 순서대로 스냅샷 구성
    const reportedById = new Map(dto.items.map((it) => [it.id, it.workStatus]));
    const ordered = (task.checklist ?? [])
      .slice()
      .sort((a, b) => a.sortOrder - b.sortOrder);
    const changed: ChecklistItem[] = [];
    const snapshot: ReportItemSnapshot[] = ordered.map((c) => {
      const ws = reportedById.get(c.id) ?? c.workStatus;
      if (reportedById.has(c.id) && c.workStatus !== ws) {
        c.workStatus = ws;
        changed.push(c);
      }
      return { itemId: c.id, text: c.text, category: c.category, workStatus: ws };
    });
    if (changed.length) {
      await this.checklistRepo.save(changed);
    }

    const reporter =
      (task.assignees ?? []).find((a) => a.id === actingUserId) ?? null;
    const note = dto.note?.trim() ? dto.note.trim() : null;

    await this.reportRepo.save(
      this.reportRepo.create({
        task: { id: task.id } as Task,
        checkpoint: { id: checkpoint.id } as TaskCheckpoint,
        reporter,
        note,
        itemsSnapshot: snapshot,
      }),
    );

    // 지시자에게 알림(행위자=작업자는 자동 제외)
    await this.notification.notify({
      recipientIds: task.issuer ? [task.issuer.id] : [],
      actorId: actingUserId,
      type: 'task.checkpoint_reported',
      payload: {
        ...this.taskNotifyPayload(project, task, reporter?.name ?? '담당자'),
        checkpointAt: checkpoint.checkAt.toISOString(),
      },
    });

    return this.buildDetail(
      await this.getTaskOrThrow(project.id, seq),
      project,
    );
  }

  // 중간 점검일 동기화 — 날짜(시각) 기준 추가/삭제. 미변경 날짜는 그대로 두어 보고를 보존.
  private async syncCheckpoints(task: Task, dates?: string[]): Promise<void> {
    if (dates === undefined) return;
    const existing = await this.checkpointRepo.find({
      where: { task: { id: task.id } },
    });
    const existingTimes = existing.map((c) => c.checkAt.getTime());
    const wantedTimes = [...new Set(dates.map((d) => new Date(d).getTime()))];

    const toRemove = existing.filter(
      (c) => !wantedTimes.includes(c.checkAt.getTime()),
    );
    if (toRemove.length) {
      await this.checkpointRepo.remove(toRemove);
    }
    const toAdd = wantedTimes
      .filter((t) => !existingTimes.includes(t))
      .map((t, i) =>
        this.checkpointRepo.create({
          task: { id: task.id } as Task,
          checkAt: new Date(t),
          sortOrder: i,
        }),
      );
    if (toAdd.length) {
      await this.checkpointRepo.save(toAdd);
    }
  }

  // 담당자(작업자) 확인 — 아니면 403
  private assertAssignee(task: Task, userId: string): void {
    const isAssignee = (task.assignees ?? []).some((a) => a.id === userId);
    if (!isAssignee) {
      throw new ForbiddenException();
    }
  }

  private async buildDetail(
    task: Task,
    project: Project,
  ): Promise<TaskDetailResponse> {
    const [comments, files, activities, checkpointRows, reportRows] =
      await Promise.all([
        this.loadComments(task.id, project, task.seq),
        this.loadFiles(task.id, project, task.seq),
        this.activity.listForTask(project.id, task.seq),
        this.checkpointRepo.find({
          where: { task: { id: task.id } },
          order: { checkAt: 'ASC' },
        }),
        this.reportRepo.find({
          where: { task: { id: task.id } },
          order: { createdAt: 'DESC' },
          relations: ['reporter', 'checkpoint'],
        }),
      ]);

    const checklist = (task.checklist ?? [])
      .slice()
      .sort((a, b) => a.sortOrder - b.sortOrder)
      .map((c) => ({
        id: c.id,
        text: c.text,
        done: c.done,
        category: c.category,
        reviewNote: c.reviewNote ?? null,
        workStatus: c.workStatus,
      }));

    const checkpoints: CheckpointResponse[] = checkpointRows.map((c) => ({
      id: c.id,
      checkAt: c.checkAt.toISOString(),
    }));
    const checkpointReports: CheckpointReportResponse[] = reportRows.map(
      (r) => ({
        id: r.id,
        checkpointId: r.checkpoint?.id ?? '',
        checkAt: r.checkpoint?.checkAt
          ? r.checkpoint.checkAt.toISOString()
          : null,
        reporter: r.reporter ? UserService.toPublic(r.reporter) : null,
        note: r.note,
        items: r.itemsSnapshot ?? [],
        createdAt: r.createdAt.toISOString(),
      }),
    );

    // 승인 정보는 승인 대기(review) 상태이고 요청 메타가 있을 때만 노출
    const approval: ApprovalInfo | null =
      task.status === 'review' && task.approvalRequestedAt
        ? {
            requester: task.approvalRequestedBy
              ? UserService.toPublic(task.approvalRequestedBy)
              : null,
            requestedAt: task.approvalRequestedAt.toISOString(),
            note: task.approvalNote,
          }
        : null;

    return {
      id: task.seq,
      projectId: project.id,
      projectName: project.name,
      title: task.title,
      status: task.status,
      content: task.content ?? [],
      checklist,
      assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)),
      issuer: task.issuer ? UserService.toPublic(task.issuer) : null,
      dueDate: task.dueDate ? task.dueDate.toISOString() : null,
      createdAt: task.createdAt.toISOString(),
      comments,
      activities,
      files,
      approval,
      changesNote: task.status === 'changes' ? task.changesNote : null,
      checkpoints,
      checkpointReports,
    };
  }

  // 최상위 댓글(+답글) 로딩 후 응답 매핑
  private async loadComments(
    taskId: string,
    project: Project,
    seq: number,
  ): Promise<CommentResponse[]> {
    const comments = await this.commentRepo.find({
      where: { task: { id: taskId }, parent: IsNull() },
      relations: ['author', 'attachment', 'replies', 'replies.author'],
      order: { createdAt: 'ASC' },
    });
    return comments.map((c) => this.toCommentResponse(c, project, seq));
  }

  // 업무 첨부 로딩 후 응답 매핑
  private async loadFiles(
    taskId: string,
    project: Project,
    seq: number,
  ): Promise<AttachmentResponse[]> {
    const files = await this.attachmentRepo.find({
      where: { task: { id: taskId } },
      relations: ['uploader'],
      order: { createdAt: 'ASC' },
    });
    return files.map((f) => this.toAttachmentResponse(f, project, seq));
  }

  // 첨부 엔티티 → 응답(다운로드 URL 포함)
  private toAttachmentResponse(
    att: Attachment,
    project: Project,
    seq: number,
  ): AttachmentResponse {
    return {
      id: att.id,
      name: att.name,
      size: att.size,
      kind: att.kind,
      url: `/api/projects/${project.id}/tasks/${seq}/files/${att.id}/download`,
      uploader: att.uploader ? UserService.toPublic(att.uploader) : null,
      createdAt: att.createdAt.toISOString(),
    };
  }

  // 댓글 엔티티 → 응답(답글 시간순 정렬)
  private toCommentResponse(
    comment: Comment,
    project: Project,
    seq: number,
  ): CommentResponse {
    const replies = (comment.replies ?? [])
      .slice()
      .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
      .map((r) => ({
        id: r.id,
        author: r.author ? UserService.toPublic(r.author) : null,
        text: r.text,
        createdAt: r.createdAt.toISOString(),
      }));
    return {
      id: comment.id,
      author: comment.author ? UserService.toPublic(comment.author) : null,
      text: comment.text,
      file: comment.attachment
        ? this.toAttachmentResponse(comment.attachment, project, seq)
        : null,
      createdAt: comment.createdAt.toISOString(),
      replies,
    };
  }
}
