import type { Request, Response, NextFunction } from 'express';
import { ALWAYS_ON_MODULES, ORG_ADMIN_ROLE, PERMISSIONS, projectRoleFor } from '../config/access.js';
import * as orgModel from '../models/tenantOrg.model.js';
import * as userModel from '../models/tenantUser.model.js';
import * as inviteModel from '../models/invite.model.js';
import * as projectModel from '../models/project.model.js';
import { effectiveAccess, roleLabel } from '../models/session.model.js';
import { firebaseAuth } from '../config/firebase.js';
import { env } from '../config/env.js';
import { decodeId } from '../utils/idCodec.js';
import { parsePagination } from '../utils/pagination.js';
import { errorResponse, successResponse } from '../utils/response.js';
import { gstinPanMismatch } from '../utils/taxIds.js';
import { imageContentType, objectKey } from '../middlewares/upload.js';
import { putFile } from '../utils/storage.js';
import { removeStoredImage } from '../utils/storedImages.js';

// /api/v1/admin/* — the tenant app's administration screens. `req.tenant` is
// set by tenantAccess(), so the organization always comes from the caller.

class BadId extends Error {}

// Body/query ids are base64url like URL ids. undefined = not sent, null = clear it.
function bodyId(value: unknown): number | null | undefined {
  if (value === undefined) return undefined;
  if (value === null || value === '') return null;
  const id = decodeId(String(value));
  if (id === null) throw new BadId();
  return id;
}

const tenantOf = (req: Request) => req.tenant!;
const isAdmin = (req: Request) => tenantOf(req).user.tenantRoles.some(({ role }) => role.name === ORG_ADMIN_ROLE);

function handle(fn: (req: Request, res: Response) => Promise<unknown>) {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      await fn(req, res);
    } catch (error) {
      if (error instanceof BadId) return void errorResponse(res, 'Invalid id', 400);
      next(error);
    }
  };
}

// ── Organization, subscription & setup ──

const getOrganization = handle(async (req, res) => {
  const org = await orgModel.getOrganization(tenantOf(req).organizationId);
  successResponse(res, 'Organization fetched successfully', await orgModel.organizationProfile(org));
});

const updateOrganization = handle(async (req, res) => {
  const { organizationId } = tenantOf(req);
  // The GSTIN must carry the organization's PAN, including when only one of them changes.
  const current = await orgModel.getOrganization(organizationId);
  const gstin = req.body.gstin === undefined ? current.gstin : req.body.gstin;
  const pan = req.body.pan === undefined ? current.pan : req.body.pan;
  const mismatch = gstin && pan ? gstinPanMismatch(gstin, pan) : null;
  if (mismatch) return void errorResponse(res, mismatch, 400, [{ field: 'gstin', message: mismatch }]);
  const org = await orgModel.updateOrganization(organizationId, req.body);
  successResponse(res, 'Organization updated successfully', await orgModel.organizationProfile(org));
});

// Organization logo in S3: orgs/<id>/logo/<uuid>.<ext>. The replaced file is removed.
const uploadLogo = handle(async (req, res) => {
  if (!req.file) return void errorResponse(res, 'Choose an image to upload', 400, [{ field: 'file', message: 'Required' }]);
  const { organizationId } = tenantOf(req);
  const key = await putFile(objectKey(`orgs/${organizationId}/logo`, req.file.originalname), req.file.buffer, imageContentType(req.file.originalname));
  const { org, previous } = await orgModel.setLogo(organizationId, key);
  await removeStoredImage(previous);
  successResponse(res, 'Logo updated', await orgModel.organizationProfile(org));
});

const removeLogo = handle(async (req, res) => {
  const { org, previous } = await orgModel.setLogo(tenantOf(req).organizationId, null);
  await removeStoredImage(previous);
  successResponse(res, 'Logo removed', await orgModel.organizationProfile(org));
});

const getSubscription = handle(async (req, res) => {
  const org = await orgModel.getOrganization(tenantOf(req).organizationId);
  successResponse(res, 'Subscription fetched successfully', await orgModel.subscriptionView(org));
});

const createSubscriptionRequest = handle(async (req, res) => {
  const { organizationId, user } = tenantOf(req);
  await orgModel.createSubscriptionRequest(organizationId, user.id, req.body);
  // The apps show `data.message` (contract: 202 { message }).
  const message = 'Sent to your BlenAxis account manager. They will reply by email.';
  successResponse(res, message, { message }, 202);
});

const getSetup = handle(async (req, res) => {
  const org = await orgModel.getOrganization(tenantOf(req).organizationId);
  successResponse(res, 'Setup fetched successfully', await orgModel.setupStatus(org));
});

const updateSetup = handle(async (req, res) => {
  const org = await orgModel.updateSetup(tenantOf(req).organizationId, req.body);
  successResponse(res, 'Setup updated successfully', await orgModel.setupStatus(org));
});

// ── Departments & teams ──

// A department head or team lead must be a user of the same organization.
async function checkPerson(req: Request, res: Response, id: number | null | undefined, what: string) {
  if (!id) return true;
  if (await userModel.findTenantUser(tenantOf(req).organizationId, id)) return true;
  errorResponse(res, `Unknown ${what}`, 400);
  return false;
}

const getDepartments = handle(async (req, res) => {
  successResponse(res, 'Departments fetched successfully', await orgModel.departmentsView(tenantOf(req).organizationId));
});

const createDepartment = handle(async (req, res) => {
  const { organizationId } = tenantOf(req);
  const headId = bodyId(req.body.headId) ?? null;
  if (await orgModel.findDepartmentByName(organizationId, req.body.name)) {
    return void errorResponse(res, `${req.body.name} already exists`, 409);
  }
  if (!(await checkPerson(req, res, headId, 'department head'))) return;
  const department = await orgModel.createDepartment(organizationId, { name: req.body.name, headId });
  successResponse(res, 'Department created successfully', await orgModel.departmentView(organizationId, department.id), 201);
});

const updateDepartment = handle(async (req, res) => {
  const { organizationId } = tenantOf(req);
  const id = Number(req.params.id);
  if (!(await orgModel.findDepartment(organizationId, id))) return void errorResponse(res, 'Department not found', 404);
  const headId = bodyId(req.body.headId);
  if (req.body.name) {
    const same = await orgModel.findDepartmentByName(organizationId, req.body.name);
    if (same && same.id !== id) return void errorResponse(res, `${req.body.name} already exists`, 409);
  }
  if (!(await checkPerson(req, res, headId, 'department head'))) return;
  await orgModel.updateDepartment(id, { ...req.body, ...(headId !== undefined ? { headId } : {}) });
  successResponse(res, 'Department updated successfully', await orgModel.departmentView(organizationId, id));
});

const deleteDepartment = handle(async (req, res) => {
  const id = Number(req.params.id);
  const department = await orgModel.findDepartment(tenantOf(req).organizationId, id);
  if (!department) return void errorResponse(res, 'Department not found', 404);
  if (await orgModel.countDepartmentMembers(id)) {
    return void errorResponse(
      res,
      `${department.name} still has people in it. Move them to another department first, or archive it.`,
      409,
    );
  }
  await orgModel.deleteDepartment(id);
  successResponse(res, 'Department deleted successfully');
});

const createTeam = handle(async (req, res) => {
  const departmentId = bodyId(req.body.departmentId)!;
  const department = await orgModel.findDepartment(tenantOf(req).organizationId, departmentId);
  if (!department) return void errorResponse(res, 'Department not found', 404);
  const leadId = bodyId(req.body.leadId) ?? null;
  if (!(await checkPerson(req, res, leadId, 'team lead'))) return;
  const team = await orgModel.createTeam({ departmentId, name: req.body.name, leadId });
  successResponse(res, 'Team created successfully', await orgModel.teamView(team.id), 201);
});

const updateTeam = handle(async (req, res) => {
  const id = Number(req.params.id);
  if (!(await orgModel.findTeam(tenantOf(req).organizationId, id))) return void errorResponse(res, 'Team not found', 404);
  const leadId = bodyId(req.body.leadId);
  if (!(await checkPerson(req, res, leadId, 'team lead'))) return;
  await orgModel.updateTeam(id, { name: req.body.name, ...(leadId !== undefined ? { leadId } : {}) });
  successResponse(res, 'Team updated successfully', await orgModel.teamView(id));
});

const deleteTeam = handle(async (req, res) => {
  const id = Number(req.params.id);
  if (!(await orgModel.findTeam(tenantOf(req).organizationId, id))) return void errorResponse(res, 'Team not found', 404);
  await orgModel.deleteTeam(id);
  successResponse(res, 'Team deleted successfully');
});

// ── Users ──

const getUsers = handle(async (req, res) => {
  const query = req.query as Record<string, string | undefined>;
  const users = await userModel.listUsers(
    tenantOf(req).organizationId,
    {
      search: query.search?.trim() || undefined,
      status: query.status || undefined,
      departmentId: bodyId(query.departmentId || undefined) ?? undefined,
      roleId: bodyId(query.roleId || undefined) ?? undefined,
    },
    parsePagination(query),
  );
  successResponse(res, 'Users fetched successfully', users);
});

const getUserOptions = handle(async (req, res) => {
  const status = (req.query.status as string | undefined) || undefined;
  successResponse(res, 'Users fetched successfully', await userModel.listUserOptions(tenantOf(req).organizationId, status));
});

const getUser = handle(async (req, res) => {
  const user = await userModel.findTenantUser(tenantOf(req).organizationId, Number(req.params.id));
  if (!user) return void errorResponse(res, 'User not found', 404);
  successResponse(res, 'User fetched successfully', userModel.userView(user));
});

const updateUser = handle(async (req, res) => {
  const { organizationId, user: me } = tenantOf(req);
  const user = await userModel.findTenantUser(organizationId, Number(req.params.id));
  if (!user) return void errorResponse(res, 'User not found', 404);

  const roleIds = (req.body.roleIds as string[] | undefined)?.map((id) => bodyId(id)!);
  if (roleIds) {
    if ((await userModel.countTenantRoles(organizationId, roleIds)) !== roleIds.length) return void errorResponse(res, 'Role not found', 404);
    const roles = await userModel.findRolesByIds(roleIds);
    const willBeAdmin = roles.some((r) => r.name === ORG_ADMIN_ROLE);
    const wasAdmin = userModel.isOrgAdmin(user);
    // Only an Organization Admin may grant or remove Organization Admin.
    if (willBeAdmin !== wasAdmin && !isAdmin(req)) return void errorResponse(res, "You don't have permission to do that", 403);
    if (user.id === me.id && wasAdmin && !willBeAdmin) {
      return void errorResponse(res, "You can't remove your own Organization Admin role", 409);
    }
    if (wasAdmin && !willBeAdmin && user.isActive && (await userModel.countOtherActiveAdmins(organizationId, user.id)) === 0) {
      return void errorResponse(res, 'Keep at least one active Organization Admin', 409);
    }
  }

  const departmentId = bodyId(req.body.departmentId);
  const teamId = bodyId(req.body.teamId);
  if (departmentId && !(await orgModel.findDepartment(organizationId, departmentId))) {
    return void errorResponse(res, 'Department not found', 404);
  }
  if (teamId) {
    const team = await orgModel.findTeam(organizationId, teamId);
    const targetDepartment = departmentId === undefined ? user.departmentId : departmentId;
    if (!team || team.departmentId !== targetDepartment) {
      return void errorResponse(res, 'That team is not in the chosen department', 400);
    }
  }

  const updated = await userModel.updateTenantUser(user.id, {
    name: req.body.name,
    phone: req.body.phone === undefined ? undefined : req.body.phone || null,
    jobTitle: req.body.jobTitle === undefined ? undefined : req.body.jobTitle || null,
    departmentId,
    // Leaving a department also leaves its team.
    teamId: teamId !== undefined ? teamId : departmentId === null ? null : undefined,
    roleIds,
  });
  successResponse(res, 'User updated successfully', userModel.userView(updated));
});

const setUserStatus = handle(async (req, res) => {
  const { organizationId, user: me } = tenantOf(req);
  const user = await userModel.findTenantUser(organizationId, Number(req.params.id));
  if (!user) return void errorResponse(res, 'User not found', 404);

  if (req.body.status === 'disabled') {
    if (user.id === me.id) return void errorResponse(res, "You can't deactivate yourself", 409);
    if (user.isActive && userModel.isOrgAdmin(user) && (await userModel.countOtherActiveAdmins(organizationId, user.id)) === 0) {
      return void errorResponse(res, 'Keep at least one active Organization Admin', 409);
    }
    await userModel.setUserActive(user.id, false);
    // A pending invite link stops working when the person is deactivated.
    return successResponse(res, 'User deactivated', userModel.userView(await userModel.clearInvite(user.id)));
  }

  if (user.isActive) return successResponse(res, 'User is already active', userModel.userView(user));
  const org = await orgModel.getOrganization(organizationId);
  const seats = await orgModel.seatsUsed(organizationId);
  if (seats >= org.maxUsers) {
    return void res.status(422).json({
      success: false,
      message: `All ${org.maxUsers} seats on your plan are in use. Request more seats, or deactivate someone first.`,
      errors: null,
      code: 'seat_limit',
    });
  }
  const reactivated = await userModel.setUserActive(user.id, true);
  // Someone who never accepted goes back to "invited" with a fresh link.
  if (!reactivated.firebaseUid) {
    return successResponse(res, 'User reactivated', userModel.userView(await inviteModel.refreshInvite(user.id, me.id)));
  }
  return successResponse(res, 'User reactivated', userModel.userView(reactivated));
});

// ── Invites & password reset ──

function seatLimitResponse(res: Response, maxUsers: number, left: number) {
  return void res.status(422).json({
    success: false,
    message: left <= 0
      ? `All ${maxUsers} seats on your plan are in use. Request more seats, or deactivate someone first.`
      : `Only ${left} seat${left === 1 ? '' : 's'} left on your plan.`,
    errors: null,
    code: 'seat_limit',
  });
}

const inviteUsers = handle(async (req, res) => {
  const { organizationId, user: me } = tenantOf(req);
  const emails = Array.from(new Set(req.body.emails as string[]));

  const existing = await inviteModel.findUsersByEmails(emails);
  const inOrg = existing.filter((u) => u.organizationId === organizationId).map((u) => u.email);
  if (inOrg.length) return void errorResponse(res, `Already in your organization: ${inOrg.join(', ')}`, 409);
  // One BlenAxis account per email address (Firebase sign-in is per email).
  if (existing.length) {
    return void errorResponse(res, `Already has a BlenAxis account: ${existing.map((u) => u.email).join(', ')}`, 409);
  }

  const roleIds = (req.body.roleIds as string[]).map((id) => bodyId(id)!);
  if ((await userModel.countTenantRoles(organizationId, roleIds)) !== roleIds.length) return void errorResponse(res, 'Role not found', 404);
  // Only an Organization Admin may invite an Organization Admin (checked on the role's internal key).
  if ((await userModel.findRolesByIds(roleIds)).some((r) => r.name === ORG_ADMIN_ROLE) && !isAdmin(req)) {
    return void errorResponse(res, "You don't have permission to do that", 403);
  }
  const roles = await userModel.listRoles(organizationId, roleIds);
  const projectIds = (req.body.projectIds as string[]).map((id) => bodyId(id)!);
  if ((await projectModel.findProjectsByIds(organizationId, projectIds)).length !== projectIds.length) {
    return void errorResponse(res, 'Project not found', 404);
  }
  // Assigned-projects roles need a project, once the organization has any.
  if (!projectIds.length && roles.every((r) => r.scope === 'assigned') && (await projectModel.countProjects(organizationId))) {
    return void errorResponse(res, 'These roles only see assigned projects, so pick at least one project.', 400, [
      { field: 'projectIds', message: 'Required' },
    ]);
  }

  const departmentId = bodyId(req.body.departmentId) ?? null;
  const teamId = bodyId(req.body.teamId) ?? null;
  if (departmentId && !(await orgModel.findDepartment(organizationId, departmentId))) return void errorResponse(res, 'Department not found', 404);
  if (teamId) {
    const team = await orgModel.findTeam(organizationId, teamId);
    if (!team || team.departmentId !== departmentId) return void errorResponse(res, 'That team is not in the chosen department', 400);
  }

  const org = await orgModel.getOrganization(organizationId);
  const left = org.maxUsers - (await orgModel.seatsUsed(organizationId));
  if (emails.length > left) return seatLimitResponse(res, org.maxUsers, left);

  const invited = await inviteModel.createInvitedUsers({
    organizationId, invitedById: me.id, emails, roleIds, departmentId, teamId,
    projectIds, projectRole: projectRoleFor(roles.map((r) => r.name)),
  });
  await Promise.all(invited.map((u) => inviteModel.sendInviteEmail(u.id)));
  successResponse(res, `Invited ${invited.length}`, { invited: invited.map(userModel.userView) }, 201);
});

const resendInvite = handle(async (req, res) => {
  const { organizationId, user: me } = tenantOf(req);
  const user = await userModel.findTenantUser(organizationId, Number(req.params.id));
  if (!user) return void errorResponse(res, 'User not found', 404);
  if (userModel.userStatus(user) !== 'invited') return void errorResponse(res, `${user.name} has already joined`, 409);
  const updated = await inviteModel.refreshInvite(user.id, me.id);
  const sent = await inviteModel.sendInviteEmail(user.id);
  successResponse(res, sent ? `Invite sent again to ${user.email}` : 'New invite link created', userModel.userView(updated));
});

const resetPassword = handle(async (req, res) => {
  const { organizationId, user: me } = tenantOf(req);
  const user = await userModel.findTenantUser(organizationId, Number(req.params.id));
  if (!user) return void errorResponse(res, 'User not found', 404);
  if (userModel.userStatus(user) !== 'active') return void errorResponse(res, 'Only active users can reset a password', 409);
  // Firebase creates the one-time reset link; Mailgun delivers it.
  const resetUrl = await firebaseAuth.generatePasswordResetLink(user.email, { url: `${env.TENANT_APP_URL}/login` });
  const sent = await inviteModel.sendPasswordResetEmail(user, me.organization!.name, resetUrl);
  const message = sent ? `Password reset link sent to ${user.email}.` : "Email isn't set up yet (Mailgun), so no reset email was sent.";
  successResponse(res, message, { message }, 202);
});

// ── Roles & module access ──

const ADMIN_PERMISSIONS = new Set(PERMISSIONS.filter((p) => p.module === 'tenant_admin').map((p) => p.name));
const adminPart = (permissions: string[]) => permissions.filter((p) => ADMIN_PERMISSIONS.has(p)).sort().join();
// Project Core is always on for every role.
const withCore = (modules: string[]) => Array.from(new Set(['project_core', ...modules]));

const getRoles = handle(async (req, res) => {
  successResponse(res, 'Roles fetched successfully', await userModel.listRoles(tenantOf(req).organizationId));
});

const getRole = handle(async (req, res) => {
  const [role] = await userModel.listRoles(tenantOf(req).organizationId, [Number(req.params.id)]);
  if (!role) return void errorResponse(res, 'Role not found', 404);
  successResponse(res, 'Role fetched successfully', role);
});

const createRole = handle(async (req, res) => {
  const { organizationId } = tenantOf(req);
  if (await userModel.roleNameTaken(organizationId, req.body.name)) {
    return void errorResponse(res, `A role called "${req.body.name}" already exists`, 409);
  }
  // Only an Organization Admin may grant Administration permissions.
  if (adminPart(req.body.permissions) && !isAdmin(req)) return void errorResponse(res, "You don't have permission to do that", 403);
  const clonedFromId = bodyId(req.body.clonedFromId);
  if (clonedFromId && !(await userModel.findTenantRole(organizationId, clonedFromId))) {
    return void errorResponse(res, 'Role not found', 404);
  }
  const id = await userModel.createCustomRole(organizationId, { ...req.body, modules: withCore(req.body.modules), clonedFromId });
  const [role] = await userModel.listRoles(organizationId, [id]);
  successResponse(res, 'Role created successfully', role, 201);
});

const updateRole = handle(async (req, res) => {
  const { organizationId } = tenantOf(req);
  const id = Number(req.params.id);
  const role = await userModel.findTenantRole(organizationId, id);
  if (!role) return void errorResponse(res, 'Role not found', 404);
  if (role.organizationId === null) {
    return void errorResponse(res, `${role.label ?? role.name} is a BlenAxis default role. Clone it to make your own version.`, 409);
  }
  if (await userModel.roleNameTaken(organizationId, req.body.name, id)) {
    return void errorResponse(res, `A role called "${req.body.name}" already exists`, 409);
  }
  const before = role.permissions.map(({ permission }) => permission.name);
  if (adminPart(before) !== adminPart(req.body.permissions) && !isAdmin(req)) {
    return void errorResponse(res, "You don't have permission to do that", 403);
  }
  await userModel.updateCustomRole(id, { ...req.body, modules: withCore(req.body.modules) });
  const [updated] = await userModel.listRoles(organizationId, [id]);
  successResponse(res, 'Role updated successfully', updated);
});

const deleteRole = handle(async (req, res) => {
  const { organizationId } = tenantOf(req);
  const id = Number(req.params.id);
  const role = await userModel.findTenantRole(organizationId, id);
  if (!role) return void errorResponse(res, 'Role not found', 404);
  if (role.organizationId === null) return void errorResponse(res, 'Default roles cannot be deleted', 409);
  const holders = await userModel.roleHolders(organizationId, id);
  const reassignTo = bodyId((req.query as Record<string, string | undefined>).reassignTo || undefined) ?? undefined;
  if (holders.length) {
    if (!reassignTo || reassignTo === id || !(await userModel.findTenantRole(organizationId, reassignTo))) {
      return void errorResponse(res, `${holders.length} people have this role. Choose a role to move them to.`, 400, [
        { field: 'reassignTo', message: 'Required' },
      ]);
    }
  }
  await userModel.deleteCustomRole(id, holders.map((h) => h.id), holders.length ? reassignTo : undefined);
  successResponse(res, 'Role deleted successfully');
});

const setModuleAccess = handle(async (req, res) => {
  const { organizationId, user } = tenantOf(req);
  const subscribed = user.organization!.moduleKeys;
  const matrix = new Map<number, string[]>();
  for (const [encoded, modules] of Object.entries(req.body as Record<string, string[]>)) {
    // Only subscribed modules can be switched on; Administration and Project Core are always on.
    const allowed = modules.filter((m) => subscribed.includes(m) && !ALWAYS_ON_MODULES.includes(m as never));
    matrix.set(bodyId(encoded)!, withCore(allowed));
  }
  await userModel.setModuleAccess(organizationId, matrix);
  successResponse(res, 'Module access saved', await userModel.listRoles(organizationId));
});

// ── Project access ──

function siteIdsFor(req: Request, res: Response, sites: { id: number }[]): number[] | null {
  const siteIds = (req.body.siteIds as string[]).map((id) => bodyId(id)!);
  if (siteIds.some((id) => !sites.some((s) => s.id === id))) {
    errorResponse(res, 'Site not found', 404);
    return null;
  }
  return siteIds;
}

const getProjectAccess = handle(async (req, res) => {
  successResponse(res, 'Project access fetched successfully', await projectModel.projectAccessView(tenantOf(req).organizationId));
});

const addProjectMembers = handle(async (req, res) => {
  const { organizationId } = tenantOf(req);
  const project = await projectModel.findProject(organizationId, Number(req.params.projectId));
  if (!project) return void errorResponse(res, 'Project not found', 404);
  const userIds = (req.body.userIds as string[]).map((id) => bodyId(id)!);
  const found = await Promise.all(userIds.map((id) => userModel.findTenantUser(organizationId, id)));
  if (found.some((u) => !u)) return void errorResponse(res, 'User not found', 404);
  const siteIds = siteIdsFor(req, res, project.sites);
  if (!siteIds) return;
  await projectModel.upsertMembers(project.id, userIds, req.body.projectRole, siteIds);
  const [view] = await projectModel.projectAccessView(organizationId, project.id);
  successResponse(res, 'Project members added', view, 201);
});

const updateProjectMember = handle(async (req, res) => {
  const { organizationId } = tenantOf(req);
  const project = await projectModel.findProject(organizationId, Number(req.params.projectId));
  const userId = Number(req.params.userId);
  if (!project || !(await projectModel.findMember(project.id, userId))) return void errorResponse(res, 'Project member not found', 404);
  const siteIds = siteIdsFor(req, res, project.sites);
  if (!siteIds) return;
  await projectModel.updateMember(project.id, userId, req.body.projectRole, siteIds);
  const [view] = await projectModel.projectAccessView(organizationId, project.id);
  successResponse(res, 'Project member updated', view);
});

const removeProjectMember = handle(async (req, res) => {
  const { organizationId } = tenantOf(req);
  const project = await projectModel.findProject(organizationId, Number(req.params.projectId));
  const userId = Number(req.params.userId);
  if (!project || !(await projectModel.findMember(project.id, userId))) return void errorResponse(res, 'Project member not found', 404);
  await projectModel.removeMember(project.id, userId);
  successResponse(res, 'Project member removed');
});

// ── Projects & sites setup ──

const scopeOf = (req: Request) => effectiveAccess(tenantOf(req).user).scope;

const getSetupProjects = handle(async (req, res) => {
  const { organizationId, user } = tenantOf(req);
  const query = req.query as Record<string, string | undefined>;
  const people = query.people === 'with' || query.people === 'without' ? query.people : undefined;
  const projects = await projectModel.setupProjects(
    organizationId, user.id, scopeOf(req), { search: query.search?.trim() || undefined, people }, parsePagination(query),
  );
  successResponse(res, 'Projects fetched successfully', projects);
});

const createSetupProject = handle(async (req, res) => {
  const { organizationId, user } = tenantOf(req);
  const body = req.body as projectModel.ProjectSetupBody;
  const [count, org] = await Promise.all([
    projectModel.countProjectsInOrganization(organizationId),
    orgModel.getOrganization(organizationId),
  ]);
  if (count >= org.maxProjects) {
    return void errorResponse(res, `Your plan allows ${org.maxProjects} projects. Ask BlenAxis to raise the limit.`, 409, [
      { field: 'name', message: 'Project limit reached' },
    ]);
  }
  if (await projectModel.projectCodeTaken(body.code)) {
    return void errorResponse(res, 'This project code is already in use', 409, [{ field: 'code', message: 'Already in use' }]);
  }
  const scope = scopeOf(req);
  const creator = scope === 'assigned'
    ? { userId: user.id, projectRole: projectRoleFor(user.tenantRoles.map(({ role }) => roleLabel(role))) }
    : undefined;
  successResponse(res, 'Project created', await projectModel.createSetupProject(organizationId, body, creator), 201);
});

const updateSetupProject = handle(async (req, res) => {
  const { organizationId, user } = tenantOf(req);
  const project = await projectModel.findSetupProject(organizationId, Number(req.params.projectId), user.id, scopeOf(req));
  if (!project) return void errorResponse(res, 'Project not found', 404);
  const body = req.body as projectModel.ProjectSetupBody;
  if (await projectModel.projectCodeTaken(body.code, project.id)) {
    return void errorResponse(res, 'This project code is already in use', 409, [{ field: 'code', message: 'Already in use' }]);
  }
  const keep = body.sites.filter((s) => s.id).map((s) => ({ id: bodyId(s.id)!, name: s.name }));
  if (keep.some((s) => !project.sites.some((site) => site.id === s.id))) return void errorResponse(res, 'Site not found', 404);
  const removed = project.sites.filter((site) => !keep.some((s) => s.id === site.id)).map((site) => site.id);
  const limited = await projectModel.membersLimitedToSites(project.id, removed);
  if (limited) {
    const people = limited === 1 ? '1 person is' : `${limited} people are`;
    return void errorResponse(res, `${people} limited to a site you removed. Change their access in Project access first.`, 409, [
      { field: 'sites', message: 'Site in use' },
    ]);
  }
  successResponse(res, 'Project updated', await projectModel.updateSetupProject(project.id, body, keep, removed));
});

const deleteSetupProject = handle(async (req, res) => {
  const { organizationId, user } = tenantOf(req);
  const project = await projectModel.findSetupProject(organizationId, Number(req.params.projectId), user.id, scopeOf(req));
  if (!project) return void errorResponse(res, 'Project not found', 404);
  // Planning data has no cascade on purpose: a planned project isn't deleted from setup.
  if (project._count.wbsItems) {
    return void errorResponse(res, 'This project already has planning data, so it can\'t be deleted', 409);
  }
  await projectModel.deleteSetupProject(project.id);
  successResponse(res, 'Project deleted');
});

export {
  getOrganization, updateOrganization, uploadLogo, removeLogo, getSubscription, createSubscriptionRequest, getSetup, updateSetup,
  getDepartments, createDepartment, updateDepartment, deleteDepartment, createTeam, updateTeam, deleteTeam,
  getUsers, getUserOptions, getUser, updateUser, setUserStatus, inviteUsers, resendInvite, resetPassword, getRoles, getRole, createRole, updateRole, deleteRole, setModuleAccess,
  getProjectAccess, addProjectMembers, updateProjectMember, removeProjectMember,
  getSetupProjects, createSetupProject, updateSetupProject, deleteSetupProject,
};
