import { Router } from 'express';
import { BugStatus, Prisma } 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 { badRequest, notFound } from '../lib/errors';
import { nextBugCode } from '../lib/ids';
import { idOrCodeWhere, isUuid, param } from '../lib/params';
import { parseListLimit, parseListOffset } from '../lib/pagination';
import {
  AuthUser,
  assertCan,
  can,
  loadProjectContext,
  visibleProjectIds,
} from '../lib/rbac';
import { AuthedRequest, requireAuth } from '../middleware/auth';
import {
  serializeBug,
  severityToBugDb,
  statusFromBugDb,
  statusToBugDb,
} from '../serializers/bug';
import {
  hasUnitTestEvidence,
  nextStatusOnAssignee,
  tryBugTransition,
} from '../services/bugWorkflow';

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

const empSelect = { id: true, name: true, code: true, avatarColor: true } as const;

const bugInclude = {
  assignments: true,
  assignee: { select: empSelect },
  reporter: { select: empSelect },
  parentTask: {
    select: {
      id: true,
      code: true,
      title: true,
      projectId: true,
      project: {
        select: {
          id: true,
          code: true,
          name: true,
          color: true,
          allowTypedEvidence: true,
        },
      },
    },
  },
} satisfies Prisma.BugInclude;

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

async function bugAccessCtx(user: AuthUser, bug: {
  assigneeId: string | null;
  status: BugStatus;
  parentTask: { projectId: string };
  assignments?: { employeeId: string }[];
}) {
  const pctx = await loadProjectContext(user.id, bug.parentTask.projectId);
  const assignIds = (bug.assignments || []).map((a) => a.employeeId);
  return {
    ...pctx,
    isBugAssignee: bug.assigneeId === user.id || assignIds.includes(user.id),
    bugStatus: statusFromBugDb(bug.status),
  };
}

function normalizeAssignments(
  raw: { employeeId: string; allocatedHours: number }[] | undefined,
  fallbackAssigneeId: string | null | undefined,
  fallbackHours: number | undefined
) {
  if (raw && raw.length) {
    const seen = new Set<string>();
    const list = [];
    for (const a of raw) {
      if (seen.has(a.employeeId)) continue;
      seen.add(a.employeeId);
      list.push({
        employeeId: a.employeeId,
        allocatedHours: Number(a.allocatedHours || 0),
      });
    }
    return list;
  }
  if (fallbackAssigneeId) {
    return [{
      employeeId: fallbackAssigneeId,
      allocatedHours: Number(fallbackHours || 0) || 1,
    }];
  }
  return [];
}

function parseStatusFilter(raw: string): BugStatus | null {
  try {
    return statusToBugDb(raw);
  } catch {
    return null;
  }
}

const upload = multer({
  storage: multer.diskStorage({
    destination: (_req, _file, cb) => {
      const dir = path.join(config.uploadDir, 'bugs');
      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 },
});

bugsRouter.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 taskId = req.query.taskId ? String(req.query.taskId) : '';
    const statusRaw = req.query.status ? String(req.query.status) : '';
    const severityRaw = req.query.severity ? String(req.query.severity) : '';
    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 blocking = req.query.blocking === '1' || req.query.blocking === 'true';
    const includeClosed =
      req.query.includeClosed === '1' || req.query.includeClosed === 'true';

    const visible = await visibleProjectIds(user);
    const where: Prisma.BugWhereInput = {};

    if (!includeClosed && !statusRaw) {
      where.status = { notIn: [BugStatus.Closed, BugStatus.Cancelled] };
    }

    if (visible !== 'all') {
      where.parentTask = { projectId: { in: visible } };
    }
    if (projectId) {
      if (visible !== 'all' && !visible.includes(projectId)) {
        where.parentTask = { projectId: { in: [] } };
      } else {
        where.parentTask = { ...(where.parentTask as object), projectId };
      }
    }
    if (clientId) {
      const parent = (where.parentTask && typeof where.parentTask === 'object'
        ? where.parentTask
        : {}) as Prisma.TaskWhereInput;
      parent.project = { clientId };
      where.parentTask = parent;
    }
    if (taskId) {
      const task = await prisma.task.findFirst({
        where: idOrCodeWhere(taskId),
        select: { id: true },
      });
      if (task) where.parentTaskId = task.id;
      else where.parentTaskId = isUuid(taskId) ? taskId : '00000000-0000-0000-0000-000000000000';
    }
    if (statusRaw) {
      const st = parseStatusFilter(statusRaw);
      if (st) where.status = st;
    }
    if (severityRaw) {
      try {
        where.severity = severityToBugDb(severityRaw);
      } catch {
        /* ignore invalid */
      }
    }
    if (assigneeId) {
      where.OR = [
        { assigneeId },
        { assignments: { some: { employeeId: assigneeId } } },
      ];
    }
    if (mine) {
      where.OR = [
        { assigneeId: user.id },
        { reporterId: user.id },
        { assignments: { some: { employeeId: user.id } } },
      ];
    }
    if (unassigned) {
      where.assigneeId = null;
      where.assignments = { none: {} };
    }
    if (blocking) where.blocking = true;

    if (q) {
      const or: Prisma.BugWhereInput[] = [
        { title: { contains: q, mode: 'insensitive' } },
        { code: { contains: q, mode: 'insensitive' } },
      ];
      try {
        or.push({ severity: severityToBugDb(q) });
      } catch {
        /* not a severity label */
      }
      where.AND = [...(Array.isArray(where.AND) ? where.AND : []), { OR: or }];
    }

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

bugsRouter.get('/:id', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const id = param(req.params.id);
    const bug = await prisma.bug.findFirst({
      where: idOrCodeWhere(id),
      include: {
        ...bugInclude,
        attachments: { orderBy: { createdAt: 'desc' } },
      },
    });
    if (!bug) throw notFound('Bug not found');

    const ctx = await bugAccessCtx(user, bug);
    assertCan(user, 'view_task', {
      ...ctx,
      isTaskAssignee: bug.assigneeId === user.id || bug.reporterId === user.id,
    });

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

    const canEdit = can(user, 'manage_bug', ctx);
    const canChange =
      can(user, 'change_bug_status', { ...ctx, toStatus: 'Closed' }) ||
      can(user, 'change_bug_status', { ...ctx, toStatus: 'In Progress' }) ||
      can(user, 'change_bug_status', { ...ctx, toStatus: 'Ready for Retest' });
    const canCreate = can(user, 'create_bug', ctx);

    const spentAgg = await prisma.timesheet.aggregate({
      where: { bugId: bug.id },
      _sum: { hours: true },
    });
    const spentByEmployee = await prisma.timesheet.groupBy({
      by: ['employeeId'],
      where: { bugId: bug.id },
      _sum: { hours: true },
    });
    const actualHours = Number(spentAgg._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({
      bug: serializeBug(bug),
      actualHours,
      actualHoursByEmployee,
      attachments: bug.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(),
        userId: a.userId,
        user: a.user,
      })),
      permissions: {
        view: true,
        edit: canEdit,
        changeStatus: canChange,
        create: canCreate,
        hasEvidence: hasUnitTestEvidence(bug),
      },
    });
  } catch (err) {
    next(err);
  }
});

const createSchema = z.object({
  title: z.string().min(1),
  parentTaskId: z.string().min(1),
  description: z.string().optional(),
  reproSteps: z.string().optional(),
  expected: z.string().optional(),
  actual: z.string().optional(),
  severity: z.string().optional(),
  priority: z.number().int().min(0).max(3).optional(),
  assigneeId: z.string().uuid().nullable().optional(),
  allocatedHours: z.number().min(0).optional(),
  assignments: z.array(assignmentSchema).optional(),
  blocking: z.boolean().optional(),
  status: z.string().optional(),
  resolutionNotes: z.string().optional(),
});

bugsRouter.post('/', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const body = createSchema.parse(req.body);
    const parent = await prisma.task.findFirst({
      where: idOrCodeWhere(body.parentTaskId),
      select: { id: true, projectId: true },
    });
    if (!parent) throw badRequest('Parent task not found');

    const pctx = await loadProjectContext(user.id, parent.projectId);
    assertCan(user, 'create_bug', pctx);

    const assignments = normalizeAssignments(
      body.assignments,
      body.assigneeId,
      body.allocatedHours ?? 2
    );
    if (!assignments.length || assignments.some((a) => !(a.allocatedHours > 0))) {
      throw badRequest('Assign at least one member with allocated time > 0');
    }
    const primaryId = assignments[0].employeeId;
    const totalHours = assignments.reduce((a, x) => a + x.allocatedHours, 0);

    let status = body.status ? statusToBugDb(body.status) : BugStatus.Open;
    status = nextStatusOnAssignee(status, primaryId);

    const code = await nextBugCode();
    const bug = await prisma.bug.create({
      data: {
        code,
        parentTaskId: parent.id,
        title: body.title.trim(),
        description: body.description || '',
        reproSteps: body.reproSteps || '',
        expected: body.expected || '',
        actual: body.actual || '',
        severity: body.severity ? severityToBugDb(body.severity) : undefined,
        priority: body.priority ?? 0,
        assigneeId: primaryId,
        reporterId: user.id,
        allocatedHours: totalHours,
        blocking: body.blocking ?? false,
        status,
        resolutionNotes: body.resolutionNotes || '',
        assignments: {
          create: assignments.map((a) => ({
            employeeId: a.employeeId,
            allocatedHours: a.allocatedHours,
          })),
        },
      },
      include: bugInclude,
    });

    await prisma.activityLog.create({
      data: {
        entityType: 'bug',
        entityId: bug.id,
        userId: user.id,
        action: 'created',
        details: { text: bug.code + ' — ' + bug.title },
      },
    });

    res.status(201).json({ bug: serializeBug(bug) });
  } catch (err) {
    next(err);
  }
});

const updateSchema = z.object({
  title: z.string().min(1).optional(),
  parentTaskId: z.string().optional(),
  description: z.string().optional(),
  reproSteps: z.string().optional(),
  expected: z.string().optional(),
  actual: z.string().optional(),
  severity: z.string().optional(),
  priority: z.number().int().min(0).max(3).optional(),
  assigneeId: z.string().uuid().nullable().optional(),
  allocatedHours: z.number().min(0).optional(),
  assignments: z.array(assignmentSchema).optional(),
  blocking: z.boolean().optional(),
  resolutionNotes: z.string().optional(),
  unitTestEvidence: z.any().nullable().optional(),
});

bugsRouter.patch('/:id', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const id = param(req.params.id);
    const body = updateSchema.parse(req.body);
    const existing = await prisma.bug.findFirst({
      where: idOrCodeWhere(id),
      include: {
        assignments: true,
        parentTask: { select: { projectId: true } },
      },
    });
    if (!existing) throw notFound('Bug not found');

    const ctx = await bugAccessCtx(user, existing);
    assertCan(user, 'manage_bug', ctx);

    let parentTaskId = existing.parentTaskId;
    if (body.parentTaskId) {
      const parent = await prisma.task.findFirst({
        where: idOrCodeWhere(body.parentTaskId),
        select: { id: true, projectId: true },
      });
      if (!parent) throw badRequest('Parent task not found');
      const pctx = await loadProjectContext(user.id, parent.projectId);
      assertCan(user, 'manage_bug', {
        ...pctx,
        isBugAssignee: existing.assigneeId === user.id,
      });
      parentTaskId = parent.id;
    }

    let assigneeId =
      body.assigneeId !== undefined ? body.assigneeId : existing.assigneeId;
    let allocatedHours =
      body.allocatedHours !== undefined
        ? body.allocatedHours
        : Number(existing.allocatedHours);

    if (body.assignments) {
      const assignments = normalizeAssignments(body.assignments, null, undefined);
      if (!assignments.length || assignments.some((a) => !(a.allocatedHours > 0))) {
        throw badRequest('Assign at least one member with allocated time > 0');
      }
      assigneeId = assignments[0].employeeId;
      allocatedHours = assignments.reduce((a, x) => a + x.allocatedHours, 0);
      await prisma.bugAssignment.deleteMany({ where: { bugId: existing.id } });
      await prisma.bugAssignment.createMany({
        data: assignments.map((a) => ({
          bugId: existing.id,
          employeeId: a.employeeId,
          allocatedHours: a.allocatedHours,
        })),
      });
    }

    let status = existing.status;
    status = nextStatusOnAssignee(status, assigneeId);

    const bug = await prisma.bug.update({
      where: { id: existing.id },
      data: {
        title: body.title?.trim(),
        parentTaskId,
        description: body.description,
        reproSteps: body.reproSteps,
        expected: body.expected,
        actual: body.actual,
        severity: body.severity ? severityToBugDb(body.severity) : undefined,
        priority: body.priority,
        assigneeId,
        allocatedHours,
        blocking: body.blocking,
        resolutionNotes: body.resolutionNotes,
        unitTestEvidence:
          body.unitTestEvidence !== undefined ? body.unitTestEvidence : undefined,
        status,
      },
      include: bugInclude,
    });

    await prisma.activityLog.create({
      data: {
        entityType: 'bug',
        entityId: bug.id,
        userId: user.id,
        action: 'updated',
        details: { text: 'Fields updated' },
      },
    });

    res.json({ bug: serializeBug(bug) });
  } catch (err) {
    next(err);
  }
});

bugsRouter.post('/:id/transition', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const id = param(req.params.id);
    const status = String(req.body?.status || '').trim();
    const details = req.body?.details != null ? String(req.body.details) : undefined;
    if (!status) throw badRequest('status is required');

    const result = await tryBugTransition(user, id, status, details);
    const bug = await prisma.bug.findUniqueOrThrow({
      where: { id: result.bug.id },
      include: bugInclude,
    });
    res.json({ ok: true, bug: serializeBug(bug), message: result.message });
  } catch (err) {
    next(err);
  }
});

/** Chatter notes / messages (stored as activity log — Note model is task-scoped). */
bugsRouter.post('/:id/notes', async (req: AuthedRequest, res, next) => {
  try {
    const user = req.user!;
    const id = param(req.params.id);
    const bug = await prisma.bug.findFirst({
      where: idOrCodeWhere(id),
      include: { assignments: true, parentTask: { select: { projectId: true } } },
    });
    if (!bug) throw notFound('Bug not found');
    const ctx = await bugAccessCtx(user, bug);
    assertCan(user, 'view_task', {
      ...ctx,
      isTaskAssignee: bug.assigneeId === user.id || bug.reporterId === user.id,
    });

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

    const row = await prisma.activityLog.create({
      data: {
        entityType: 'bug',
        entityId: bug.id,
        userId: user.id,
        action: body.kind === 'message' ? 'message' : 'note',
        details: { text: body.body },
      },
      include: { user: { select: empSelect } },
    });

    res.status(201).json({
      note: {
        id: row.id,
        body: body.body,
        kind: body.kind,
        userId: row.userId,
        user: row.user,
        createdAt: row.at.toISOString(),
        updatedAt: row.at.toISOString(),
      },
    });
  } catch (err) {
    next(err);
  }
});

bugsRouter.post(
  '/:id/evidence',
  upload.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 existing = await prisma.bug.findFirst({
        where: idOrCodeWhere(id),
        include: {
          assignments: true,
          parentTask: {
            select: {
              projectId: true,
              project: { select: { allowTypedEvidence: true } },
            },
          },
        },
      });
      if (!existing) throw notFound('Bug not found');

      const ctx = await bugAccessCtx(user, existing);
      if (!can(user, 'upload_evidence', ctx) && !can(user, 'manage_bug', ctx)) {
        assertCan(user, 'upload_evidence', ctx);
      }

      const prev = (existing.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,
            bugId: existing.id,
            uploadedById: user.id,
          },
        });
        attachmentId = attachment.id;
        evidenceType = 'file';
      } else if (!attachmentId && !existing.parentTask.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 bug = await prisma.bug.update({
        where: { id: existing.id },
        data: { unitTestEvidence },
        include: bugInclude,
      });

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

      res.status(201).json({ ok: true, bug: serializeBug(bug) });
    } catch (err) {
      next(err);
    }
  }
);
