/**
 * PR8 — Multi-mentor stacking + change-request procedures
 *
 * Covers the API surface added by PR8 Tasks 4 + 6:
 *   - mentor.assign: per-team stacking, P2002 on duplicate (projectId, mentorId),
 *     idempotent per-row email notification (via MentorAssignment.notificationSentAt),
 *     re-assignment after drop creates a new row and re-fires the email.
 *   - mentor.requestChange: auth (team-member or admin), validation, single open
 *     request per (user, project), target-assignment cross-project guard.
 *   - mentor.listChangeRequests: admin-only, PENDING-first ordering.
 *   - mentor.resolveChangeRequest: admin-only, BAD_REQUEST on already-resolved.
 */

import { afterAll, describe, expect, it } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
  createTestUser,
  createTestProgram,
  createTestProject,
  cleanupTestData,
  uid,
} from '../helpers'
import { mentorRouter } from '../../src/server/routers/mentor'
import type { UserRole } from '@prisma/client'

async function createUserWithRoles(
  primaryRole: UserRole,
  rolesArray: UserRole[],
  overrides: { name?: string; expertiseTags?: string[] } = {},
) {
  const id = uid('user')
  return prisma.user.create({
    data: {
      id,
      email: `${id}@test.local`,
      name: overrides.name ?? `Test ${primaryRole}`,
      role: primaryRole,
      roles: rolesArray,
      status: 'ACTIVE',
      expertiseTags: overrides.expertiseTags ?? [],
    },
  })
}

/**
 * mentor.assign and mentor.bulkAssign now require the project to be enrolled
 * in some MENTORING round. This helper sets up the minimum: one competition
 * + one MENTORING round + one ProjectRoundState linking the project.
 */
async function attachToMentoringRound(programId: string, projectId: string) {
  const compSlug = `comp-${uid()}`
  const competition = await prisma.competition.create({
    data: {
      name: `Comp ${compSlug}`,
      slug: compSlug,
      programId,
      status: 'ACTIVE',
    },
  })
  const round = await prisma.round.create({
    data: {
      name: `Mentoring ${uid()}`,
      slug: `mentoring-${uid()}`,
      roundType: 'MENTORING',
      sortOrder: 1,
      status: 'ROUND_ACTIVE',
      competitionId: competition.id,
    },
  })
  await prisma.projectRoundState.create({
    data: { roundId: round.id, projectId },
  })
  return { competitionId: competition.id, roundId: round.id }
}

describe('mentor.assign — stacking + per-team email idempotency', () => {
  const programIds: string[] = []
  const userIds: string[] = []

  afterAll(async () => {
    for (const programId of programIds) {
      await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
      await cleanupTestData(programId, [])
    }
    if (userIds.length > 0) {
      await prisma.user.deleteMany({ where: { id: { in: userIds } } })
    }
  })

  it('stacks two different mentors on the same project (both rows active)', async () => {
    const admin = await createTestUser('SUPER_ADMIN')
    userIds.push(admin.id)
    const program = await createTestProgram({ name: `assign-stack-${uid()}` })
    programIds.push(program.id)
    const project = await createTestProject(program.id, { title: 'Stacking Project' })
    await attachToMentoringRound(program.id, project.id)

    const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M1' })
    const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M2' })
    userIds.push(m1.id, m2.id)

    const caller = createCaller(mentorRouter, {
      id: admin.id,
      email: admin.email,
      role: 'SUPER_ADMIN',
    })
    const a1 = await caller.assign({ projectId: project.id, mentorId: m1.id })
    const a2 = await caller.assign({ projectId: project.id, mentorId: m2.id })

    expect(a1.id).not.toBe(a2.id)
    expect(a1.mentorId).toBe(m1.id)
    expect(a2.mentorId).toBe(m2.id)

    const rows = await prisma.mentorAssignment.findMany({
      where: { projectId: project.id },
      orderBy: { assignedAt: 'asc' },
    })
    expect(rows).toHaveLength(2)
    expect(rows.every((r) => r.droppedAt === null)).toBe(true)
  })

  it('rejects duplicate (projectId, mentorId) pair with CONFLICT', async () => {
    const admin = await createTestUser('SUPER_ADMIN')
    userIds.push(admin.id)
    const program = await createTestProgram({ name: `assign-dup-${uid()}` })
    programIds.push(program.id)
    const project = await createTestProject(program.id, { title: 'Dup Project' })
    await attachToMentoringRound(program.id, project.id)
    const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
    userIds.push(mentor.id)

    const caller = createCaller(mentorRouter, {
      id: admin.id,
      email: admin.email,
      role: 'SUPER_ADMIN',
    })

    await caller.assign({ projectId: project.id, mentorId: mentor.id })
    await expect(
      caller.assign({ projectId: project.id, mentorId: mentor.id }),
    ).rejects.toThrow(/already assigned/i)
  })

  it('stamps notificationSentAt on first assignment; fires fresh email when same mentor is added to a different project', async () => {
    const admin = await createTestUser('SUPER_ADMIN')
    userIds.push(admin.id)
    const program = await createTestProgram({ name: `assign-email-${uid()}` })
    programIds.push(program.id)
    const project1 = await createTestProject(program.id, { title: 'Project Alpha' })
    await attachToMentoringRound(program.id, project1.id)
    const project2 = await createTestProject(program.id, { title: 'Project Beta' })
    await attachToMentoringRound(program.id, project2.id)
    const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
    userIds.push(mentor.id)

    const caller = createCaller(mentorRouter, {
      id: admin.id,
      email: admin.email,
      role: 'SUPER_ADMIN',
    })

    const a1 = await caller.assign({ projectId: project1.id, mentorId: mentor.id })
    const a2 = await caller.assign({ projectId: project2.id, mentorId: mentor.id })

    // assign() returns the row before the post-write stamp; re-read for the
    // current value.
    const row1 = await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
    const row2 = await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })

    expect(row1?.notificationSentAt).not.toBeNull()
    expect(row2?.notificationSentAt).not.toBeNull()
    // Each row carries its own timestamp — they're independent.
    expect(row1?.id).not.toBe(row2?.id)
  })

  it('stamps notificationSentAt independently for each co-mentor on the same project', async () => {
    const admin = await createTestUser('SUPER_ADMIN')
    userIds.push(admin.id)
    const program = await createTestProgram({ name: `assign-comentor-${uid()}` })
    programIds.push(program.id)
    const project = await createTestProject(program.id, { title: 'Co-mentor Project' })
    await attachToMentoringRound(program.id, project.id)
    const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-1' })
    const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-2' })
    userIds.push(m1.id, m2.id)

    const caller = createCaller(mentorRouter, {
      id: admin.id,
      email: admin.email,
      role: 'SUPER_ADMIN',
    })
    const a1 = await caller.assign({ projectId: project.id, mentorId: m1.id })
    const a2 = await caller.assign({ projectId: project.id, mentorId: m2.id })

    const row1 = await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
    const row2 = await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })

    expect(row1?.notificationSentAt).not.toBeNull()
    expect(row2?.notificationSentAt).not.toBeNull()
  })

  it('after a mentor is dropped (assignment row deleted), re-assigning creates a fresh row with a new notificationSentAt', async () => {
    const admin = await createTestUser('SUPER_ADMIN')
    userIds.push(admin.id)
    const program = await createTestProgram({ name: `assign-redrop-${uid()}` })
    programIds.push(program.id)
    const project = await createTestProject(program.id, { title: 'Re-assign Project' })
    await attachToMentoringRound(program.id, project.id)
    const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
    userIds.push(mentor.id)

    const caller = createCaller(mentorRouter, {
      id: admin.id,
      email: admin.email,
      role: 'SUPER_ADMIN',
    })

    const a1 = await caller.assign({ projectId: project.id, mentorId: mentor.id })
    const stamp1 = (
      await prisma.mentorAssignment.findUnique({ where: { id: a1.id } })
    )?.notificationSentAt
    expect(stamp1).not.toBeNull()

    // Hard-delete the first row (simulates a "fully dropped → repository clean"
    // state — the unique constraint also blocks any re-assign while the row
    // exists, so the row must go away).
    await prisma.mentorAssignment.delete({ where: { id: a1.id } })

    // Re-assign: new row, new notificationSentAt stamp.
    const a2 = await caller.assign({ projectId: project.id, mentorId: mentor.id })
    const stamp2 = (
      await prisma.mentorAssignment.findUnique({ where: { id: a2.id } })
    )?.notificationSentAt

    expect(a2.id).not.toBe(a1.id)
    expect(stamp2).not.toBeNull()
  })
})

describe('mentor.requestChange / listChangeRequests / resolveChangeRequest', () => {
  const programIds: string[] = []
  const userIds: string[] = []

  afterAll(async () => {
    for (const programId of programIds) {
      await prisma.mentorChangeRequest.deleteMany({ where: { project: { programId } } })
      await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
      await prisma.teamMember.deleteMany({ where: { project: { programId } } })
      await cleanupTestData(programId, [])
    }
    if (userIds.length > 0) {
      await prisma.user.deleteMany({ where: { id: { in: userIds } } })
    }
  })

  /**
   * Builds a project with a LEAD team member (applicant), an unrelated
   * non-team-member (applicant), and an admin. Returns the IDs.
   */
  async function setupProjectWithTeam(label: string) {
    const admin = await createTestUser('SUPER_ADMIN', { name: `Admin ${label}` })
    userIds.push(admin.id)
    const program = await createTestProgram({ name: `${label}-${uid()}` })
    programIds.push(program.id)
    const project = await createTestProject(program.id, { title: `Project ${label}` })
    const teamMember = await createUserWithRoles('APPLICANT', ['APPLICANT'], {
      name: `Team ${label}`,
    })
    const outsider = await createUserWithRoles('APPLICANT', ['APPLICANT'], {
      name: `Outsider ${label}`,
    })
    userIds.push(teamMember.id, outsider.id)
    await prisma.teamMember.create({
      data: {
        projectId: project.id,
        userId: teamMember.id,
        role: 'LEAD',
      },
    })
    return { admin, program, project, teamMember, outsider }
  }

  it('team member can open a change request (PENDING)', async () => {
    const { project, teamMember } = await setupProjectWithTeam('rc-teamok')
    const caller = createCaller(mentorRouter, {
      id: teamMember.id,
      email: teamMember.email,
      role: 'APPLICANT',
    })
    const created = await caller.requestChange({
      projectId: project.id,
      reason: 'We would like a mentor with deeper marine biology experience.',
    })
    expect(created.status).toBe('PENDING')

    const persisted = await prisma.mentorChangeRequest.findUnique({
      where: { id: created.id },
    })
    expect(persisted?.requestedByUserId).toBe(teamMember.id)
    expect(persisted?.projectId).toBe(project.id)
  })

  it('non-team-member non-admin is rejected with FORBIDDEN', async () => {
    const { project, outsider } = await setupProjectWithTeam('rc-outsider')
    const caller = createCaller(mentorRouter, {
      id: outsider.id,
      email: outsider.email,
      role: 'APPLICANT',
    })
    await expect(
      caller.requestChange({
        projectId: project.id,
        reason: 'I have no business asking for this.',
      }),
    ).rejects.toThrow(/FORBIDDEN|not a member/i)
  })

  it('admin (no team membership) can open a change request', async () => {
    const { admin, project } = await setupProjectWithTeam('rc-admin')
    const caller = createCaller(mentorRouter, {
      id: admin.id,
      email: admin.email,
      role: 'SUPER_ADMIN',
    })
    const created = await caller.requestChange({
      projectId: project.id,
      reason: 'Admin-initiated mentor swap due to internal escalation.',
    })
    expect(created.status).toBe('PENDING')
  })

  it('reason < 10 chars is rejected (Zod validation)', async () => {
    const { project, teamMember } = await setupProjectWithTeam('rc-short')
    const caller = createCaller(mentorRouter, {
      id: teamMember.id,
      email: teamMember.email,
      role: 'APPLICANT',
    })
    await expect(
      caller.requestChange({ projectId: project.id, reason: 'too short' }),
    ).rejects.toThrow()
  })

  it('opening a second request while the first is still PENDING throws CONFLICT', async () => {
    const { project, teamMember } = await setupProjectWithTeam('rc-conflict')
    const caller = createCaller(mentorRouter, {
      id: teamMember.id,
      email: teamMember.email,
      role: 'APPLICANT',
    })
    await caller.requestChange({
      projectId: project.id,
      reason: 'First request — still pending please review.',
    })
    await expect(
      caller.requestChange({
        projectId: project.id,
        reason: 'Second request while first is open.',
      }),
    ).rejects.toThrow(/already.*open|CONFLICT/i)
  })

  it('after the first request is resolved, the same user can open a new one', async () => {
    const { admin, project, teamMember } = await setupProjectWithTeam('rc-reopen')
    const teamCaller = createCaller(mentorRouter, {
      id: teamMember.id,
      email: teamMember.email,
      role: 'APPLICANT',
    })
    const adminCaller = createCaller(mentorRouter, {
      id: admin.id,
      email: admin.email,
      role: 'SUPER_ADMIN',
    })

    const first = await teamCaller.requestChange({
      projectId: project.id,
      reason: 'First request — please address my concerns.',
    })
    await adminCaller.resolveChangeRequest({
      id: first.id,
      status: 'RESOLVED',
      resolutionNote: 'Mentor swapped.',
    })

    const second = await teamCaller.requestChange({
      projectId: project.id,
      reason: 'Second request — new concern after resolution.',
    })
    expect(second.status).toBe('PENDING')
    expect(second.id).not.toBe(first.id)
  })

  it('targetAssignmentId belonging to a different project is rejected with BAD_REQUEST', async () => {
    const { admin, project: projectA, teamMember } = await setupProjectWithTeam('rc-crossproj')
    // Make a second project + mentor assignment NOT on the requester's project.
    const otherProgram = await createTestProgram({ name: `rc-other-${uid()}` })
    programIds.push(otherProgram.id)
    const otherProject = await createTestProject(otherProgram.id, { title: 'Other proj' })
    const mentor = await createUserWithRoles('MENTOR', ['MENTOR'])
    userIds.push(mentor.id)
    const foreignAssignment = await prisma.mentorAssignment.create({
      data: {
        projectId: otherProject.id,
        mentorId: mentor.id,
        method: 'MANUAL',
        assignedBy: admin.id,
      },
    })

    const caller = createCaller(mentorRouter, {
      id: teamMember.id,
      email: teamMember.email,
      role: 'APPLICANT',
    })
    await expect(
      caller.requestChange({
        projectId: projectA.id,
        targetAssignmentId: foreignAssignment.id,
        reason: 'Trying to point at a foreign assignment row.',
      }),
    ).rejects.toThrow(/does not belong|BAD_REQUEST/i)
  })

  it('listChangeRequests is FORBIDDEN for applicant', async () => {
    const { project, teamMember } = await setupProjectWithTeam('rc-list-forbidden')
    const teamCaller = createCaller(mentorRouter, {
      id: teamMember.id,
      email: teamMember.email,
      role: 'APPLICANT',
    })
    await teamCaller.requestChange({
      projectId: project.id,
      reason: 'A real request, but list should still be admin-only.',
    })
    await expect(teamCaller.listChangeRequests({})).rejects.toThrow()
  })

  it('listChangeRequests returns PENDING rows before non-PENDING rows', async () => {
    const { admin, project, teamMember } = await setupProjectWithTeam('rc-list-order')
    const teamCaller = createCaller(mentorRouter, {
      id: teamMember.id,
      email: teamMember.email,
      role: 'APPLICANT',
    })
    const adminCaller = createCaller(mentorRouter, {
      id: admin.id,
      email: admin.email,
      role: 'SUPER_ADMIN',
    })

    // Create two requests: open one, resolve it; open a second one (still PENDING).
    const resolvedReq = await teamCaller.requestChange({
      projectId: project.id,
      reason: 'Will be resolved before the second request opens.',
    })
    await adminCaller.resolveChangeRequest({
      id: resolvedReq.id,
      status: 'RESOLVED',
    })
    const pendingReq = await teamCaller.requestChange({
      projectId: project.id,
      reason: 'Still pending — should be listed first.',
    })

    const rows = (await adminCaller.listChangeRequests({ projectId: project.id })) as Array<{
      id: string
      status: string
    }>
    const ids = rows.map((r) => r.id)
    // PENDING must come before RESOLVED in the listing.
    expect(ids.indexOf(pendingReq.id)).toBeLessThan(ids.indexOf(resolvedReq.id))
    expect(rows[0].status).toBe('PENDING')
  })

  it('resolveChangeRequest sets resolvedBy/resolvedAt/resolutionNote', async () => {
    const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve')
    const teamCaller = createCaller(mentorRouter, {
      id: teamMember.id,
      email: teamMember.email,
      role: 'APPLICANT',
    })
    const adminCaller = createCaller(mentorRouter, {
      id: admin.id,
      email: admin.email,
      role: 'SUPER_ADMIN',
    })

    const req = await teamCaller.requestChange({
      projectId: project.id,
      reason: 'Please resolve this request.',
    })
    const result = await adminCaller.resolveChangeRequest({
      id: req.id,
      status: 'RESOLVED',
      resolutionNote: 'Replacement mentor assigned.',
    })
    expect(result.status).toBe('RESOLVED')
    expect(result.resolvedByUserId).toBe(admin.id)
    expect(result.resolvedAt).not.toBeNull()
    expect(result.resolutionNote).toBe('Replacement mentor assigned.')
  })

  it('resolveChangeRequest by non-admin is FORBIDDEN', async () => {
    const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve-forbid')
    const teamCaller = createCaller(mentorRouter, {
      id: teamMember.id,
      email: teamMember.email,
      role: 'APPLICANT',
    })
    const adminCaller = createCaller(mentorRouter, {
      id: admin.id,
      email: admin.email,
      role: 'SUPER_ADMIN',
    })
    const req = await adminCaller.requestChange({
      projectId: project.id,
      reason: 'Admin opens, applicant should not resolve.',
    })
    await expect(
      teamCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED' }),
    ).rejects.toThrow()
  })

  it('resolveChangeRequest on an already-resolved request throws BAD_REQUEST', async () => {
    const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve-twice')
    const teamCaller = createCaller(mentorRouter, {
      id: teamMember.id,
      email: teamMember.email,
      role: 'APPLICANT',
    })
    const adminCaller = createCaller(mentorRouter, {
      id: admin.id,
      email: admin.email,
      role: 'SUPER_ADMIN',
    })
    const req = await teamCaller.requestChange({
      projectId: project.id,
      reason: 'Will resolve, then try to resolve again.',
    })
    await adminCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED' })
    await expect(
      adminCaller.resolveChangeRequest({ id: req.id, status: 'DISMISSED' }),
    ).rejects.toThrow(/already resolved|BAD_REQUEST/i)
  })
})
