import { Router } from 'express';
import { Prisma, SystemRole, TaskStage } from '@prisma/client';
import fs from 'fs';
import multer from 'multer';
import path from 'path';
import { z } from 'zod';
import { config } from '../config';
import { prisma } from '../db';
import { toDateOnly } from '../lib/dates';
import { badRequest, notFound } from '../lib/errors';
import { nextTaskCode } from '../lib/ids';
import { idOrCodeWhere, param } from '../lib/params';
import { parseListLimit, parseListOffset } from '../lib/pagination';
import {
  AuthUser,
  assertCan,
  can,
  loadProjectContext,
  visibleProjectIds,
} from '../lib/rbac';
import { stageFromDb, stageToDb } from '../lib/stages';
import { AuthedRequest, requireAuth } from '../middleware/auth';
import {
  billingToDb,
  qaResultToDb,
  serializeTask,
} from '../serializers/task';
import {
  reopenTask,
  returnToInbox,
  returnToInProgress,
  tryTransition,
} from '../services/workflow';

export const tasksRouter = Router();
tasksRouter.use(requireAuth);

const taskUpload = multer({
  storage: multer.diskStorage({
    destination: (_req, _file, cb) => {
      const dir = path.join(config.uploadDir, 'tasks');
      fs.mkdirSync(dir, { recursive: true });
      cb(null, dir);
    },
    filename: (_req, file, cb) => {
      const safe = file.originalname.replace(/[^\w.\-()+ ]+/g, '_').slice(0, 120);
      cb(null, Date.now() + '-' + safe);
    },
  }),
  limits: { fileSize: 15 * 1024 * 1024 },
});

const taskInclude = {
  assignments: true,
  assignee: { select: { id: true, name: true, code: true, avatarColor: true } },
  project: {
    select: {
      id: true,
      code: true,
      name: true,
      color: true,
      clientId: true,
      client: { select: { id: true, code: true, name: true } },
    },
  },
  internalStatus: { select: { id: true, text: true, color: true, stage: true } },
  _count: { select: { subtasks: true, bugs: true } },
} satisfies Prisma.TaskInclude;

async function taskAccessCtx(user: AuthUser, task: {
  projectId: string;
  assigneeId: string | null;
  contributors: string[];
  stage: TaskStage;
  assignments?: { employeeId: string }[];
}) {
  const pctx = await loadProjectContext(user.id, task.projectId);
  const assignIds = (task.assignments || []).map((a) => a.employeeId);
  const isAssignee =
    task.assigneeId === user.id ||
    assignIds.includes(user.id) ||
    (task.contributors || []).includes(user.id);
  return {
    ...pctx,
    isTaskAssignee: isAssignee,
    taskStageDone: task.stage === TaskStage.Done,
  };
}

tasksRouter.get('/', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const q = String(req.query.q || '').trim();
    const projectId = req.query.projectId ? String(req.query.projectId) : '';
    const clientId = req.query.clientId ? String(req.query.clientId) : '';
    const stage = req.query.stage ? String(req.query.stage) : '';
    const assigneeId = req.query.assigneeId ? String(req.query.assigneeId) : '';
    const mine = req.query.mine === '1' || req.query.mine === 'true';
    const unassigned = req.query.unassigned === '1' || req.query.unassigned === 'true';
    const includeClosed =
      req.query.includeClosed === '1' || req.query.includeClosed === 'true';
    const priority =
      req.query.priority !== undefined && req.query.priority !== ''
        ? Number(req.query.priority)
        : null;
    const parentOnly = req.query.parentOnly !== '0' && req.query.parentOnly !== 'false';
    const parentId = req.query.parentId ? String(req.query.parentId) : '';

    const visible = await visibleProjectIds(user);
    const where: Prisma.TaskWhereInput = {};
    if (!includeClosed) where.lifecycle = 'Open';
    if (visible !== 'all') where.projectId = { in: visible };
    if (projectId) {
      where.projectId =
        visible === 'all' || visible.includes(projectId)
          ? projectId
          : { in: [] };
    }
    if (clientId) where.project = { clientId };
    if (parentId) where.parentId = parentId;
    else if (parentOnly) where.parentId = null;
    if (stage) where.stage = stageToDb(stage);
    if (priority != null && !Number.isNaN(priority)) where.priority = priority;
    if (assigneeId) {
      where.OR = [
        { assigneeId },
        { assignments: { some: { employeeId: assigneeId } } },
      ];
    }
    if (mine) {
      where.OR = [
        { assigneeId: user.id },
        { assignments: { some: { employeeId: user.id } } },
        { contributors: { has: user.id } },
      ];
    }
    if (unassigned) {
      where.assigneeId = null;
      where.assignments = { none: {} };
    }
    if (q) {
      where.AND = [
        ...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []),
        {
          OR: [
            { title: { contains: q, mode: 'insensitive' } },
            { code: { contains: q, mode: 'insensitive' } },
            { tags: { has: q } },
          ],
        },
      ];
    }

    const limit = parseListLimit(req.query.limit);
    const offset = parseListOffset(req.query.offset);
    const rows = await prisma.task.findMany({
      where,
      include: taskInclude,
      orderBy: [{ code: 'asc' }],
      take: limit + 1,
      skip: offset,
    });
    const hasMore = rows.length > limit;
    const page = hasMore ? rows.slice(0, limit) : rows;
    res.json({
      tasks: page.map(serializeTask),
      limit,
      offset,
      hasMore,
    });
  } catch (err) {
    next(err);
  }
});

tasksRouter.get('/:id', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const id = param(req.params.id);
    const task = await prisma.task.findFirst({
      where: { OR: [{ id }, { code: id }] },
      include: {
        ...taskInclude,
        notes: {
          where: { deleted: false },
          orderBy: { createdAt: 'desc' },
          include: {
            user: { select: { id: true, name: true, code: true, avatarColor: true } },
          },
        },
        plannedActivities: {
          orderBy: { dueDate: 'asc' },
          include: {
            assignee: { select: { id: true, name: true, code: true } },
          },
        },
        subtasks: {
          include: taskInclude,
          orderBy: { code: 'asc' },
        },
        attachments: { orderBy: { createdAt: 'desc' } },
      },
    });
    if (!task) throw notFound('Task not found');

    const ctx = await taskAccessCtx(user, task);
    if (!can(user, 'view_task', ctx) && user.role !== SystemRole.SuperAdmin) {
      // BA/Dev/QA on project already covered by isProjectMember
      assertCan(user, 'view_task', ctx);
    }
    assertCan(user, 'view_task', ctx);

    const activities = await prisma.activityLog.findMany({
      where: { entityType: 'task', entityId: task.id },
      orderBy: { at: 'desc' },
      take: 50,
      include: { user: { select: { id: true, name: true, code: true } } },
    });

    const taskIds = [task.id, ...task.subtasks.map((s) => s.id)];
    const spentAgg = await prisma.timesheet.aggregate({
      where: { taskId: { in: taskIds }, bugId: null },
      _sum: { hours: true },
    });
    const spentDirectAgg = await prisma.timesheet.aggregate({
      where: { taskId: task.id, bugId: null },
      _sum: { hours: true },
    });
    const spentByEmployee = await prisma.timesheet.groupBy({
      by: ['employeeId'],
      where: { taskId: { in: taskIds }, bugId: null },
      _sum: { hours: true },
    });
    const actualHours = Number(spentAgg._sum.hours || 0);
    const actualHoursDirect = Number(spentDirectAgg._sum.hours || 0);
    const actualHoursByEmployee: Record<string, number> = {};
    spentByEmployee.forEach((row) => {
      actualHoursByEmployee[row.employeeId] = Math.round(Number(row._sum.hours || 0) * 100) / 100;
    });

    res.json({
      task: serializeTask(task),
      actualHours,
      actualHoursDirect,
      actualHoursByEmployee,
      subtasks: task.subtasks.map(serializeTask),
      notes: task.notes.map((n) => ({
        id: n.id,
        body: n.body,
        kind: n.kind,
        userId: n.userId,
        user: n.user,
        createdAt: n.createdAt.toISOString(),
        updatedAt: n.updatedAt.toISOString(),
      })),
      plannedActivities: task.plannedActivities.map((a) => ({
        id: a.id,
        type: a.type,
        summary: a.summary,
        dueDate: a.dueDate ? a.dueDate.toISOString().slice(0, 10) : null,
        assigneeId: a.assigneeId,
        assignee: a.assignee,
        note: a.note,
        status: a.status,
      })),
      attachments: task.attachments.map((a) => ({
        id: a.id,
        name: a.name,
        size: a.size,
        mimeType: a.mimeType,
        kind: a.kind,
        createdAt: a.createdAt.toISOString(),
      })),
      activities: activities.map((a) => ({
        id: a.id,
        action: a.action,
        details: a.details,
        at: a.at.toISOString(),
        user: a.user,
      })),
      permissions: {
        view: true,
        edit: can(user, 'edit_task', ctx),
        changeStatus: can(user, 'change_task_status', ctx),
      },
    });
  } catch (err) {
    next(err);
  }
});

const assignmentSchema = z.object({
  employeeId: z.string().uuid(),
  allocatedHours: z.number().min(0),
});

const createSchema = z.object({
  title: z.string().trim().min(1),
  projectId: z.string().uuid(),
  description: z.string().optional().default(''),
  acceptanceCriteria: z.string().optional().default(''),
  priority: z.number().int().min(0).max(3).optional().default(0),
  tags: z.array(z.string()).optional().default([]),
  assigneeId: z.string().uuid().nullable().optional(),
  contributors: z.array(z.string().uuid()).optional().default([]),
  assignments: z.array(assignmentSchema).optional().default([]),
  allocatedHours: z.number().min(0).optional(),
  billingType: z.enum(['Billable', 'Non-billable']),
  startDate: z.string().nullable().optional(),
  dueDate: z.string().nullable().optional(),
  parentId: z.string().uuid().nullable().optional(),
  required: z.boolean().optional().default(false),
  stage: z.string().optional(),
  internalStatusId: z.string().uuid().nullable().optional(),
  lifecycle: z.enum(['Open', 'Done', 'Cancelled']).optional(),
});

tasksRouter.post('/', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const body = createSchema.parse(req.body);
    const project = await prisma.project.findUnique({
      where: { id: body.projectId },
      include: { internalStatuses: true },
    });
    if (!project) throw badRequest('Project not found');

    if (body.parentId) {
      const parent = await prisma.task.findUnique({ where: { id: body.parentId } });
      if (!parent) throw badRequest('Parent task not found');
      if (parent.parentId) {
        throw badRequest('Subtasks cannot have their own subtasks. Add the subtask under the main task.');
      }
      if (parent.projectId !== body.projectId) {
        throw badRequest('Subtask must belong to the same project as the parent task');
      }
    }

    const pctx = await loadProjectContext(user.id, project.id);
    assertCan(user, 'edit_task', { ...pctx, isProjectMember: true });

    const stage = body.stage ? stageToDb(body.stage) : TaskStage.Backlog;
    let internalStatusId = body.internalStatusId || null;
    if (!internalStatusId) {
      const fallback = project.internalStatuses.find(
        (s) => s.stage === stage && s.active
      );
      internalStatusId = fallback?.id ?? null;
    }

    const assignments = body.assignments.length
      ? body.assignments
      : body.assigneeId
        ? [{ employeeId: body.assigneeId, allocatedHours: body.allocatedHours || 0 }]
        : [];
    const primaryAssignee = assignments[0]?.employeeId || body.assigneeId || null;
    const allocatedHours =
      body.allocatedHours != null
        ? body.allocatedHours
        : assignments.reduce((a, x) => a + x.allocatedHours, 0);

    const code = await nextTaskCode(project.code);

    const task = await prisma.task.create({
      data: {
        code,
        title: body.title,
        projectId: body.projectId,
        description: body.description || '',
        acceptanceCriteria: body.acceptanceCriteria || '',
        stage,
        lifecycle: body.lifecycle || 'Open',
        internalStatusId,
        priority: body.priority ?? 0,
        tags: body.tags || [],
        assigneeId: primaryAssignee,
        contributors: body.contributors || [],
        allocatedHours,
        billingType: billingToDb(body.billingType),
        startDate: toDateOnly(body.startDate),
        dueDate: toDateOnly(body.dueDate),
        parentId: body.parentId || null,
        required: body.required ?? false,
        assignments: {
          create: assignments.map((a) => ({
            employeeId: a.employeeId,
            allocatedHours: a.allocatedHours,
          })),
        },
      },
      include: taskInclude,
    });

    await prisma.activityLog.create({
      data: {
        entityType: 'task',
        entityId: task.id,
        userId: user.id,
        action: 'created',
        details: { text: `Created task ${task.code}` },
      },
    });

    res.status(201).json({ task: serializeTask(task) });
  } catch (err) {
    next(err);
  }
});

/** Partial update — no Zod defaults (defaults from createSchema would wipe fields on status-only PATCH). */
const updateSchema = z.object({
  title: z.string().trim().min(1).optional(),
  projectId: z.string().uuid().optional(),
  description: z.string().optional(),
  acceptanceCriteria: z.string().optional(),
  priority: z.number().int().min(0).max(3).optional(),
  tags: z.array(z.string()).optional(),
  assigneeId: z.string().uuid().nullable().optional(),
  contributors: z.array(z.string().uuid()).optional(),
  assignments: z.array(assignmentSchema).optional(),
  allocatedHours: z.number().min(0).optional(),
  billingType: z.enum(['Billable', 'Non-billable']).optional(),
  startDate: z.string().nullable().optional(),
  dueDate: z.string().nullable().optional(),
  parentId: z.string().uuid().nullable().optional(),
  required: z.boolean().optional(),
  stage: z.string().optional(),
  internalStatusId: z.string().uuid().nullable().optional(),
  lifecycle: z.enum(['Open', 'Done', 'Cancelled']).optional(),
  baAnalysisComplete: z.boolean().optional(),
  developmentComplete: z.boolean().optional(),
  unitTestEvidence: z.any().nullable().optional(),
  developerCompletionLog: z.string().nullable().optional(),
  codeReviewApproved: z.boolean().optional(),
  qaResult: z.enum(['Passed', 'Failed', 'Pending']).nullable().optional(),
  qaEvidence: z.any().nullable().optional(),
  acceptanceSatisfied: z.boolean().optional(),
  testerApproval: z.boolean().optional(),
  dependencies: z.array(z.string().uuid()).optional(),
});

tasksRouter.patch('/:id', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const id = param(req.params.id);
    const existing = await prisma.task.findFirst({
      where: { OR: [{ id }, { code: id }] },
      include: { assignments: true },
    });
    if (!existing) throw notFound('Task not found');

    const ctx = await taskAccessCtx(user, existing);
    assertCan(user, 'edit_task', ctx);

    const body = updateSchema.parse(req.body);

    await prisma.$transaction(async (tx) => {
      const data: Prisma.TaskUpdateInput = {};
      if (body.title !== undefined) data.title = body.title;
      if (body.description !== undefined) data.description = body.description;
      if (body.acceptanceCriteria !== undefined) {
        data.acceptanceCriteria = body.acceptanceCriteria;
      }
      if (body.priority !== undefined) data.priority = body.priority;
      if (body.tags !== undefined) data.tags = body.tags;
      if (body.contributors !== undefined) data.contributors = body.contributors;
      if (body.billingType !== undefined) data.billingType = billingToDb(body.billingType);
      if (body.startDate !== undefined) data.startDate = toDateOnly(body.startDate);
      if (body.dueDate !== undefined) data.dueDate = toDateOnly(body.dueDate);
      if (body.required !== undefined) data.required = body.required;
      if (body.dependencies !== undefined) data.dependencies = body.dependencies;
      if (body.baAnalysisComplete !== undefined) {
        data.baAnalysisComplete = body.baAnalysisComplete;
      }
      if (body.developmentComplete !== undefined) {
        data.developmentComplete = body.developmentComplete;
      }
      if (body.unitTestEvidence !== undefined) {
        data.unitTestEvidence = body.unitTestEvidence;
      }
      if (body.developerCompletionLog !== undefined) {
        data.developerCompletionLog = body.developerCompletionLog;
      }
      if (body.codeReviewApproved !== undefined) {
        data.codeReviewApproved = body.codeReviewApproved;
      }
      if (body.qaResult !== undefined) data.qaResult = qaResultToDb(body.qaResult);
      if (body.qaEvidence !== undefined) data.qaEvidence = body.qaEvidence;
      if (body.acceptanceSatisfied !== undefined) {
        data.acceptanceSatisfied = body.acceptanceSatisfied;
      }
      if (body.testerApproval !== undefined) data.testerApproval = body.testerApproval;
      if (body.internalStatusId !== undefined) {
        data.internalStatus = body.internalStatusId
          ? { connect: { id: body.internalStatusId } }
          : { disconnect: true };
      }
      if (body.lifecycle !== undefined) {
        data.lifecycle = body.lifecycle;
        if (body.lifecycle === 'Done' && !existing.completionDate) {
          data.completionDate = new Date();
          data.completedById = user.id;
        }
        if (body.lifecycle === 'Open') {
          data.completionDate = null;
          data.completedById = null;
        }
      }
      if (body.projectId !== undefined) {
        data.project = { connect: { id: body.projectId } };
      }
      if (body.parentId !== undefined) {
        if (body.parentId) {
          const parent = await prisma.task.findUnique({ where: { id: body.parentId } });
          if (!parent) throw badRequest('Parent task not found');
          if (parent.parentId) {
            throw badRequest('Subtasks cannot have their own subtasks. Add the subtask under the main task.');
          }
          if (existing.parentId === null && (await prisma.task.count({ where: { parentId: existing.id } })) > 0) {
            throw badRequest('Cannot convert a parent task into a subtask while it still has subtasks');
          }
        }
        data.parent = body.parentId
          ? { connect: { id: body.parentId } }
          : { disconnect: true };
      }

      if (body.assignments !== undefined) {
        await tx.taskAssignment.deleteMany({ where: { taskId: existing.id } });
        if (body.assignments.length) {
          await tx.taskAssignment.createMany({
            data: body.assignments.map((a) => ({
              taskId: existing.id,
              employeeId: a.employeeId,
              allocatedHours: a.allocatedHours,
            })),
          });
        }
        const primary = body.assignments[0]?.employeeId || null;
        data.assignee = primary
          ? { connect: { id: primary } }
          : { disconnect: true };
        data.allocatedHours = body.assignments.reduce(
          (a, x) => a + x.allocatedHours,
          0
        );
      } else if (body.assigneeId !== undefined) {
        data.assignee = body.assigneeId
          ? { connect: { id: body.assigneeId } }
          : { disconnect: true };
      }
      if (body.allocatedHours !== undefined && !body.assignments) {
        data.allocatedHours = body.allocatedHours;
      }

      await tx.task.update({ where: { id: existing.id }, data });
    });

    const updated = await prisma.task.findUniqueOrThrow({
      where: { id: existing.id },
      include: taskInclude,
    });
    res.json({ task: serializeTask(updated) });
  } catch (err) {
    next(err);
  }
});

tasksRouter.post('/:id/transition', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const id = param(req.params.id);
    const body = z
      .object({
        stage: z.string().min(1),
        reason: z.string().optional(),
      })
      .parse(req.body);
    await tryTransition(user, id, body.stage, { reason: body.reason });
    const task = await prisma.task.findFirst({
      where: { OR: [{ id }, { code: id }] },
      include: taskInclude,
    });
    res.json({
      ok: true,
      task: task ? serializeTask(task) : null,
      message: `Moved to ${body.stage}`,
    });
  } catch (err) {
    next(err);
  }
});

tasksRouter.post('/:id/reopen', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const id = param(req.params.id);
    const body = z.object({ reason: z.string().min(1) }).parse(req.body);
    await reopenTask(user, id, body.reason);
    const task = await prisma.task.findFirst({
      where: { OR: [{ id }, { code: id }] },
      include: taskInclude,
    });
    res.json({ ok: true, task: task ? serializeTask(task) : null });
  } catch (err) {
    next(err);
  }
});

tasksRouter.post('/:id/return-to-in-progress', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const id = param(req.params.id);
    const body = z.object({ reason: z.string().min(1) }).parse(req.body);
    await returnToInProgress(user, id, body.reason);
    const task = await prisma.task.findFirst({
      where: { OR: [{ id }, { code: id }] },
      include: taskInclude,
    });
    res.json({ ok: true, task: task ? serializeTask(task) : null });
  } catch (err) {
    next(err);
  }
});

tasksRouter.post('/:id/return-to-inbox', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const id = param(req.params.id);
    const body = z.object({ reason: z.string().min(1) }).parse(req.body);
    await returnToInbox(user, id, body.reason);
    const task = await prisma.task.findFirst({
      where: { OR: [{ id }, { code: id }] },
      include: taskInclude,
    });
    res.json({ ok: true, task: task ? serializeTask(task) : null });
  } catch (err) {
    next(err);
  }
});

// Notes (chatter)
tasksRouter.post('/:id/notes', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const id = param(req.params.id);
    const task = await prisma.task.findFirst({
      where: { OR: [{ id }, { code: id }] },
      include: { assignments: true },
    });
    if (!task) throw notFound('Task not found');
    assertCan(user, 'view_task', await taskAccessCtx(user, task));

    const body = z
      .object({
        body: z.string().trim().min(1),
        kind: z.enum(['note', 'message']).default('note'),
      })
      .parse(req.body);

    const note = await prisma.note.create({
      data: {
        taskId: task.id,
        userId: user.id,
        body: body.body,
        kind: body.kind,
      },
      include: {
        user: { select: { id: true, name: true, code: true, avatarColor: true } },
      },
    });
    res.status(201).json({
      note: {
        id: note.id,
        body: note.body,
        kind: note.kind,
        userId: note.userId,
        user: note.user,
        createdAt: note.createdAt.toISOString(),
        updatedAt: note.updatedAt.toISOString(),
      },
    });
  } catch (err) {
    next(err);
  }
});

tasksRouter.patch('/notes/:noteId', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const noteId = param(req.params.noteId);
    const note = await prisma.note.findUnique({ where: { id: noteId } });
    if (!note || note.deleted) throw notFound('Note not found');
    if (note.userId !== user.id && user.role !== SystemRole.SuperAdmin) {
      throw badRequest('You can only edit your own notes');
    }
    const body = z.object({ body: z.string().trim().min(1) }).parse(req.body);
    const updated = await prisma.note.update({
      where: { id: noteId },
      data: { body: body.body },
    });
    res.json({ note: { id: updated.id, body: updated.body } });
  } catch (err) {
    next(err);
  }
});

tasksRouter.delete('/notes/:noteId', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const noteId = param(req.params.noteId);
    const note = await prisma.note.findUnique({ where: { id: noteId } });
    if (!note) throw notFound('Note not found');
    if (note.userId !== user.id && user.role !== SystemRole.SuperAdmin) {
      throw badRequest('You can only delete your own notes');
    }
    await prisma.note.update({
      where: { id: noteId },
      data: { deleted: true },
    });
    res.json({ ok: true });
  } catch (err) {
    next(err);
  }
});

// Planned activities
tasksRouter.post('/:id/activities', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const id = param(req.params.id);
    const task = await prisma.task.findFirst({
      where: { OR: [{ id }, { code: id }] },
      include: { assignments: true },
    });
    if (!task) throw notFound('Task not found');
    assertCan(user, 'view_task', await taskAccessCtx(user, task));

    const body = z
      .object({
        type: z.enum(['todo', 'email', 'call', 'meeting', 'document']),
        summary: z.string().trim().min(1),
        dueDate: z.string().nullable().optional(),
        assigneeId: z.string().uuid(),
        note: z.string().optional().default(''),
      })
      .parse(req.body);

    const act = await prisma.plannedActivity.create({
      data: {
        taskId: task.id,
        type: body.type,
        summary: body.summary,
        dueDate: toDateOnly(body.dueDate),
        assigneeId: body.assigneeId,
        note: body.note || '',
      },
    });
    res.status(201).json({ activity: act });
  } catch (err) {
    next(err);
  }
});

tasksRouter.post('/activities/:activityId/done', async (req: AuthedRequest, res, next) => {
  try {
    const activityId = param(req.params.activityId);
    const act = await prisma.plannedActivity.update({
      where: { id: activityId },
      data: { status: 'done' },
    });
    res.json({ activity: act });
  } catch (err) {
    next(err);
  }
});

/** General task attachment upload */
tasksRouter.post(
  '/:id/attachments',
  taskUpload.single('file'),
  async (req: AuthedRequest, res, next) => {
    try {
      const user = req.user!;
      const id = param(req.params.id);
      if (!req.file) throw badRequest('A file is required');

      const task = await prisma.task.findFirst({
        where: idOrCodeWhere(id),
        include: { assignments: true },
      });
      if (!task) throw notFound('Task not found');

      const ctx = await taskAccessCtx(user, task);
      if (!can(user, 'edit_task', ctx) && !can(user, 'view_task', ctx)) {
        assertCan(user, 'view_task', ctx);
      }

      const kind = String(req.body?.kind || 'file').trim() || 'file';
      const relPath = path.relative(config.uploadDir, req.file.path);
      const attachment = await prisma.attachment.create({
        data: {
          name: req.file.originalname,
          size: req.file.size,
          mimeType: req.file.mimetype || 'application/octet-stream',
          kind,
          storagePath: relPath,
          taskId: task.id,
          uploadedById: user.id,
        },
      });

      await prisma.activityLog.create({
        data: {
          entityType: 'task',
          entityId: task.id,
          userId: user.id,
          action: 'attachment',
          details: { text: 'Uploaded ' + attachment.name },
        },
      });

      res.status(201).json({
        attachment: {
          id: attachment.id,
          name: attachment.name,
          size: attachment.size,
          mimeType: attachment.mimeType,
          kind: attachment.kind,
          createdAt: attachment.createdAt.toISOString(),
        },
      });
    } catch (err) {
      next(err);
    }
  }
);

/** QA result: status, content note, and optional multi-file evidence */
tasksRouter.post(
  '/:id/qa-result',
  taskUpload.array('files', 20),
  async (req: AuthedRequest, res, next) => {
    try {
      const user = req.user!;
      const id = param(req.params.id);
      const note = String(req.body?.note || '').trim();
      const resultRaw = String(req.body?.qaResult || req.body?.result || '').trim();
      if (!['Passed', 'Failed', 'Pending'].includes(resultRaw)) {
        throw badRequest('QA result must be Passed, Failed, or Pending.');
      }
      if (!note || /^done\.?$/i.test(note)) {
        throw badRequest('A meaningful QA evidence note is required (not just "done").');
      }

      const task = await prisma.task.findFirst({
        where: idOrCodeWhere(id),
        include: {
          assignments: true,
          attachments: { select: { id: true, kind: true } },
          project: { select: { id: true, adminId: true } },
        },
      });
      if (!task) throw notFound('Task not found');

      const ctx = await taskAccessCtx(user, task);
      if (!can(user, 'edit_task', ctx) && !can(user, 'upload_evidence', ctx)) {
        assertCan(user, 'edit_task', ctx);
      }

      const files = (req.files as Express.Multer.File[]) || [];
      const createdIds: string[] = [];
      for (const file of files) {
        const relPath = path.relative(config.uploadDir, file.path);
        const attachment = await prisma.attachment.create({
          data: {
            name: file.originalname,
            size: file.size,
            mimeType: file.mimetype || 'application/octet-stream',
            kind: 'qa-evidence',
            storagePath: relPath,
            taskId: task.id,
            uploadedById: user.id,
          },
        });
        createdIds.push(attachment.id);
      }

      const prevEv = (task.qaEvidence || {}) as {
        attachmentId?: string;
        attachmentIds?: string[];
      };
      const existingQaIds = (task.attachments || [])
        .filter((a) => a.kind === 'qa-evidence')
        .map((a) => a.id);
      const attachmentIds = Array.from(
        new Set(
          (prevEv.attachmentIds || [])
            .concat(existingQaIds)
            .concat(prevEv.attachmentId ? [prevEv.attachmentId] : [])
            .concat(createdIds)
        )
      );

      const qaEvidence = {
        type: attachmentIds.length ? 'file' : 'typed',
        note,
        attachmentId: attachmentIds[0] || null,
        attachmentIds,
        at: new Date().toISOString(),
        uploadedBy: user.id,
      };

      const data: Prisma.TaskUpdateInput = {
        qaResult: qaResultToDb(resultRaw as 'Passed' | 'Failed' | 'Pending'),
        qaEvidence,
      };
      if (resultRaw === 'Passed') {
        data.testerApproval = true;
        data.acceptanceSatisfied = true;
      } else if (resultRaw === 'Failed') {
        data.testerApproval = false;
      }

      const updated = await prisma.task.update({
        where: { id: task.id },
        data,
        include: taskInclude,
      });

      await prisma.activityLog.create({
        data: {
          entityType: 'task',
          entityId: task.id,
          userId: user.id,
          action: 'qa_result',
          details: {
            text:
              resultRaw +
              ' — ' +
              note +
              (createdIds.length
                ? ' (' + createdIds.length + ' file' + (createdIds.length === 1 ? '' : 's') + ')'
                : ''),
          },
        },
      });

      res.status(201).json({
        ok: true,
        task: serializeTask(updated),
        uploaded: createdIds.length,
      });
    } catch (err) {
      next(err);
    }
  }
);

/** Unit-test evidence: typed note and/or uploaded file */
tasksRouter.post(
  '/:id/evidence',
  taskUpload.single('file'),
  async (req: AuthedRequest, res, next) => {
    try {
      const user = req.user!;
      const id = param(req.params.id);
      const note = String(req.body?.note || '').trim();
      if (!note || /^done\.?$/i.test(note)) {
        throw badRequest('A meaningful evidence note is required (not just "done").');
      }

      const task = await prisma.task.findFirst({
        where: idOrCodeWhere(id),
        include: {
          assignments: true,
          project: { select: { id: true, allowTypedEvidence: true, adminId: true } },
        },
      });
      if (!task) throw notFound('Task not found');

      const ctx = await taskAccessCtx(user, task);
      assertCan(user, 'upload_evidence', ctx);

      const prev = (task.unitTestEvidence || {}) as { attachmentId?: string };
      let attachmentId: string | null = prev.attachmentId || null;
      let evidenceType: 'file' | 'typed' = attachmentId ? 'file' : 'typed';

      if (req.file) {
        const relPath = path.relative(config.uploadDir, req.file.path);
        const attachment = await prisma.attachment.create({
          data: {
            name: req.file.originalname,
            size: req.file.size,
            mimeType: req.file.mimetype || 'application/octet-stream',
            kind: 'unit-test',
            storagePath: relPath,
            taskId: task.id,
            uploadedById: user.id,
          },
        });
        attachmentId = attachment.id;
        evidenceType = 'file';
      } else if (!attachmentId && !task.project.allowTypedEvidence) {
        throw badRequest(
          'This project requires a unit-test evidence file. Upload a file with your note.'
        );
      }

      const unitTestEvidence = {
        type: evidenceType,
        note,
        attachmentId,
        at: new Date().toISOString(),
        uploadedBy: user.id,
      };

      const updated = await prisma.task.update({
        where: { id: task.id },
        data: { unitTestEvidence },
        include: taskInclude,
      });

      await prisma.activityLog.create({
        data: {
          entityType: 'task',
          entityId: task.id,
          userId: user.id,
          action: 'evidence',
          details: {
            text:
              'Unit-test evidence (' +
              evidenceType +
              '): ' +
              note +
              (req.file ? ' — ' + req.file.originalname : ''),
          },
        },
      });

      res.status(201).json({ ok: true, task: serializeTask(updated) });
    } catch (err) {
      next(err);
    }
  }
);

// Keep stageFromDb referenced for debugging helpers
void stageFromDb;
