import 'dotenv/config';
import assert from 'node:assert/strict';
import type { Server } from 'node:http';
import { after, before, beforeEach, describe, test } from 'node:test';
import { FAKE_FIREBASE_PRIVATE_KEY } from './support/fakeFirebaseEnv.js';

// DB-backed tests for /api/v1/admin/* (tenant isolation, guard rails, seats).
// They need a real Postgres database that is WIPED on every run, so they only
// run when TEST_DATABASE_URL is set: `npm run test:db` (see README).
const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL;

describe('tenant admin API (database)', { skip: !TEST_DATABASE_URL && 'TEST_DATABASE_URL is not set' }, () => {
  let server: Server;
  let baseURL: string;
  let prisma: any;
  let encodeId: (id: number) => string;
  const tokens = new Map<string, string>();
  const ids: Record<string, number> = {};
  // Outgoing Mailgun requests and Firebase accounts created, captured by the stubs below.
  const mails: { template: string; to: string; variables: any }[] = [];
  const firebaseAccounts: string[] = [];

  before(async () => {
    process.env.DATABASE_URL = TEST_DATABASE_URL;
    process.env.NODE_ENV = 'test';
    process.env.FIREBASE_PROJECT_ID = 'test-project';
    process.env.FIREBASE_CLIENT_EMAIL = 'test@test-project.iam.gserviceaccount.com';
    process.env.FIREBASE_PRIVATE_KEY = FAKE_FIREBASE_PRIVATE_KEY;
    process.env.FIREBASE_WEB_API_KEY = 'test-web-api-key';
    process.env.RATE_LIMIT_MAX = '10000';
    process.env.AUTH_RATE_LIMIT_MAX = '10000';
    process.env.MAILGUN_API_KEY = 'test-key';
    process.env.MAILGUN_DOMAIN = 'mg.example.com';
    process.env.TENANT_APP_URL = 'https://app.example.com';
    // Independent of any real Mailgun settings in the local .env.
    process.env.MAILGUN_TEMPLATE_INVITE = 'blenaxis-invite';
    process.env.MAILGUN_TEMPLATE_PASSWORD_RESET = 'blenaxis-password-reset';
    process.env.MAILGUN_TEMPLATE_ACCESS_REQUEST = 'blenaxis-access-request';
    process.env.MAILGUN_REGION = 'us';
    process.env.MAIL_FROM = '';

    const { default: app } = await import('../src/app.js');
    ({ default: prisma } = await import('../src/config/db.js'));
    ({ encodeId } = await import('../src/utils/idCodec.js'));
    const { firebaseAuth } = await import('../src/config/firebase.js');
    // Firebase isn't reachable in tests: a bearer token maps straight to a uid.
    firebaseAuth.verifyIdToken = (async (token: string) => {
      const uid = tokens.get(token);
      if (!uid) throw new Error('invalid token');
      return { uid };
    }) as typeof firebaseAuth.verifyIdToken;

    // Accepting an invite creates the Firebase account; resetting generates a link.
    firebaseAuth.createUser = (async ({ email }: { email: string }) => {
      firebaseAccounts.push(email);
      const uid = `uid-new-${firebaseAccounts.length}`;
      tokens.set(`token-${uid}`, uid);
      return { uid, email };
    }) as never;
    firebaseAuth.generatePasswordResetLink = (async (email: string) => `https://reset.example.com/?email=${email}`) as never;
    const realFetch = globalThis.fetch;
    globalThis.fetch = (async (input: any, init?: any) => {
      const url = typeof input === 'string' ? input : input.url;
      if (url.startsWith('https://api.mailgun.net/v3/mg.example.com/messages')) {
        const form = new URLSearchParams(init.body);
        mails.push({ template: form.get('template')!, to: form.get('to')!, variables: JSON.parse(form.get('t:variables')!) });
        return new Response(JSON.stringify({ id: 'queued' }), { status: 200 });
      }
      if (url.startsWith('https://identitytoolkit.googleapis.com')) {
        const { email } = JSON.parse(init.body);
        return new Response(JSON.stringify({ localId: 'x', idToken: `id-${email}`, refreshToken: 'r' }), { status: 200 });
      }
      return realFetch(input, init);
    }) as typeof fetch;

    server = app.listen(0, '127.0.0.1');
    await new Promise<void>((resolve) => server.once('listening', () => resolve()));
    const address = server.address();
    baseURL = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}`;
  });

  after(async () => {
    await new Promise<void>((resolve) => server.close(() => resolve()));
    await prisma.$disconnect();
  });

  // Two tenants: Eastfield (two admins, a site engineer) and Skyline (one admin).
  beforeEach(async () => {
    mails.length = 0;
    await prisma.$executeRawUnsafe(
      'TRUNCATE users, organizations, departments, teams, user_roles, subscription_requests, projects, sites, project_members, access_requests RESTART IDENTITY CASCADE',
    );
    const { seedDemoProjects, seedPermissions, seedRoleAccess, seedRoles } = await import('../prisma/seedData.js');
    await seedRoles(prisma);
    await seedPermissions(prisma);
    await seedRoleAccess(prisma);

    const org = (code: string, name: string, maxUsers: number) =>
      prisma.organization.create({
        data: { code, name, maxUsers, planName: 'Growth', moduleKeys: ['tenant_admin', 'project_core', 'planning'] },
      });
    const eastfield = await org('eastfield', 'Eastfield Developers', 4);
    const skyline = await org('skyline', 'Skyline Constructions', 25);
    ids.eastfield = eastfield.id;
    ids.skyline = skyline.id;
    // Eastfield gets the demo projects (EF-01 with three towers, RS-02); Skyline has none.
    await seedDemoProjects(prisma, eastfield.id);

    const role = async (name: string) => (await prisma.role.findUniqueOrThrow({ where: { name } })).id;
    const user = async (key: string, organizationId: number, roleName: string) => {
      const created = await prisma.user.create({
        data: {
          name: key, email: `${key}@example.com`, firebaseUid: `uid-${key}`, organizationId,
          roleId: await role('user'), tenantRoles: { create: { roleId: await role(roleName) } },
        },
      });
      ids[key] = created.id;
      tokens.set(`token-${key}`, `uid-${key}`);
    };
    await user('anita', eastfield.id, 'organization-admin');
    await user('karan', eastfield.id, 'organization-admin');
    await user('rahul', eastfield.id, 'site-engineer');
    await user('vikram', skyline.id, 'organization-admin');
  });

  const call = async (who: string, method: string, path: string, body?: unknown) => {
    const res = await fetch(`${baseURL}/api/v1/admin${path}`, {
      method,
      headers: { Authorization: `Bearer token-${who}`, 'Content-Type': 'application/json' },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    const json: any = await res.json();
    return { status: res.status, data: json.data, body: json };
  };
  const id = (key: string) => encodeId(ids[key]);
  const callApi = async (who: string, method: string, path: string, body?: unknown) => {
    const res = await fetch(`${baseURL}/api/v1${path}`, {
      method,
      headers: { Authorization: `Bearer token-${who}`, 'Content-Type': 'application/json' },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    const json: any = await res.json();
    return { status: res.status, data: json.data, body: json };
  };

  test('organization profile: admins edit it, a site engineer is refused', async () => {
    assert.equal((await call('rahul', 'GET', '/organization')).status, 403);

    const bad = await call('anita', 'PATCH', '/organization', { name: 'Eastfield', gstin: 'NOT-A-GSTIN' });
    assert.equal(bad.status, 400);
    // A typo fails the GSTIN check digit; a PAN with an unknown holder type is refused.
    const typo = await call('anita', 'PATCH', '/organization', { gstin: '27AABCE1234F1Z6' });
    assert.match(typo.body.errors[0].message, /check digit/);
    assert.equal((await call('anita', 'PATCH', '/organization', { pan: 'AABXE1234F' })).status, 400);

    const ok = await call('anita', 'PATCH', '/organization', {
      name: 'Eastfield Developers', legalName: 'Eastfield Developers Pvt Ltd', gstin: '27aabce1234f1z5', pan: 'AABCE1234F',
      address: 'Baner Road, Pune', timezone: 'Asia/Kolkata', currency: 'INR', financialYearStartMonth: 4,
      contactEmail: 'ops@eastfield.example',
    });
    assert.equal(ok.status, 200);
    assert.equal(ok.data.gstin, '27AABCE1234F1Z5');
    // Changing only the PAN can't break the GSTIN ↔ PAN link.
    const mismatch = await call('anita', 'PATCH', '/organization', { pan: 'AABCS5678K' });
    assert.equal(mismatch.status, 400);
    assert.match(mismatch.body.message, /belongs to PAN AABCE1234F/);
    assert.equal((await call('anita', 'GET', '/setup')).data.steps.find((s: any) => s.key === 'profile').done, true);
  });

  test('subscription shows seats for the caller’s own organization only', async () => {
    const eastfield = await call('anita', 'GET', '/subscription');
    assert.deepEqual(eastfield.data.usage.maxUsers, 3);
    assert.equal(eastfield.data.limits.maxUsers, 4);
    const skyline = await call('vikram', 'GET', '/subscription');
    assert.equal(skyline.data.usage.maxUsers, 1);

    const request = await call('anita', 'POST', '/subscription/requests', { kind: 'seats', message: '10 more seats' });
    assert.equal(request.status, 202);
    assert.match(request.data.message, /account manager/, 'the app shows data.message');
  });

  test('departments and teams: create, duplicate, delete rules, and tenant isolation', async () => {
    const created = await call('anita', 'POST', '/departments', { name: 'Projects', headId: id('karan') });
    assert.equal(created.status, 201);
    assert.equal(created.data.headName, 'karan');
    assert.equal((await call('anita', 'POST', '/departments', { name: 'projects' })).status, 409);

    const team = await call('anita', 'POST', '/teams', { departmentId: created.data.id, name: 'Civil Team', leadId: id('rahul') });
    assert.equal(team.status, 201);
    assert.equal(team.data.leadName, 'rahul');
    const setup = await call('anita', 'GET', '/setup');
    assert.equal(setup.data.steps.find((s: any) => s.key === 'departments').done, true);

    // Skyline can neither see nor touch Eastfield's department; a foreign head is rejected.
    assert.deepEqual((await call('vikram', 'GET', '/departments')).data, []);
    assert.equal((await call('vikram', 'PATCH', `/departments/${created.data.id}`, { name: 'Hijacked' })).status, 404);
    assert.equal((await call('vikram', 'POST', '/departments', { name: 'Sales', headId: id('anita') })).status, 400);

    // A department with people can't be deleted; archiving works; deleting a team keeps its people.
    await call('anita', 'PATCH', `/users/${id('rahul')}`, { departmentId: created.data.id, teamId: team.data.id });
    assert.equal((await call('anita', 'DELETE', `/departments/${created.data.id}`)).status, 409);
    assert.equal((await call('anita', 'PATCH', `/departments/${created.data.id}`, { archived: true })).data.archived, true);
    assert.equal((await call('anita', 'DELETE', `/teams/${team.data.id}`)).status, 200);
    const rahul = await call('anita', 'GET', `/users/${id('rahul')}`);
    assert.equal(rahul.data.department, 'Projects');
    assert.equal(rahul.data.teamId, undefined);
  });

  test('users: filters, detail and another tenant’s user is not found', async () => {
    const all = await call('anita', 'GET', '/users');
    assert.deepEqual(all.data.items.map((u: any) => u.name), ['anita', 'karan', 'rahul']);
    assert.deepEqual(all.data.pagination, { page: 1, limit: 20, total: 3, totalPages: 1 });
    assert.deepEqual(all.data.statusCounts, { all: 3, active: 3, invited: 0, disabled: 0 });
    // Paging and the options list for pickers.
    const second = await call('anita', 'GET', '/users?page=2&limit=2');
    assert.deepEqual(second.data.items.map((u: any) => u.name), ['rahul']);
    assert.equal(second.data.pagination.totalPages, 2);
    const options = await call('anita', 'GET', '/users/options');
    assert.deepEqual(options.data.map((u: any) => [u.name, u.status, u.roles]), [
      ['anita', 'active', ['Organization Admin']], ['karan', 'active', ['Organization Admin']], ['rahul', 'active', ['Site Engineer']],
    ]);
    assert.deepEqual((await call('vikram', 'GET', '/users/options')).data.map((u: any) => u.name), ['vikram']);

    const roles = await call('anita', 'GET', '/roles');
    const engineerRole = roles.data.find((r: any) => r.name === 'Site Engineer');
    assert.equal(engineerRole.userCount, 1);
    assert.equal(engineerRole.isSystem, true);
    const engineers = await call('anita', 'GET', `/users?roleId=${engineerRole.id}`);
    assert.deepEqual(engineers.data.items.map((u: any) => u.name), ['rahul']);
    assert.deepEqual((await call('anita', 'GET', '/users?search=KAR')).data.items.map((u: any) => u.name), ['karan']);

    assert.equal((await call('anita', 'GET', `/users/${id('vikram')}`)).status, 404);
    assert.equal((await call('rahul', 'GET', '/users')).status, 403);
  });

  test('role changes keep at least one Organization Admin, and nobody drops their own', async () => {
    const roles = (await call('anita', 'GET', '/roles')).data;
    const roleId = (name: string) => roles.find((r: any) => r.name === name).id;

    const own = await call('anita', 'PATCH', `/users/${id('anita')}`, { roleIds: [roleId('Director')] });
    assert.equal(own.status, 409);

    const karan = await call('anita', 'PATCH', `/users/${id('karan')}`, { roleIds: [roleId('Project Manager')] });
    assert.equal(karan.status, 200);
    assert.deepEqual(karan.data.roles, ['Project Manager']);

    // Anita is now the last admin: Karan can't demote her (he's no longer an admin at all).
    const byKaran = await call('karan', 'PATCH', `/users/${id('anita')}`, { roleIds: [roleId('Viewer')] });
    assert.equal(byKaran.status, 403);

    const multi = await call('anita', 'PATCH', `/users/${id('rahul')}`, {
      roleIds: [roleId('Site Engineer'), roleId('QS')], jobTitle: 'Site Engineer',
    });
    assert.deepEqual(multi.data.roles.sort(), ['QS', 'Site Engineer']);
  });

  test('custom roles: clone, unique names, per-organization, and default roles stay read-only', async () => {
    const roles = (await call('anita', 'GET', '/roles')).data;
    const engineer = roles.find((r: any) => r.name === 'Site Engineer');

    const input = {
      name: 'Site Manager', description: 'Runs one tower', scope: 'assigned', clonedFromId: engineer.id,
      permissions: [...engineer.permissions, 'execution.site.approve'], modules: engineer.modules,
    };
    const created = await call('anita', 'POST', '/roles', input);
    assert.equal(created.status, 201);
    assert.equal(created.data.isSystem, false);
    assert.equal(created.data.clonedFromId, engineer.id);
    assert.ok(created.data.permissions.includes('execution.site.approve'));
    assert.equal((await call('anita', 'POST', '/roles', { ...input, name: 'site manager' })).status, 409);
    assert.equal((await call('anita', 'POST', '/roles', { ...input, name: 'Site Engineer' })).status, 409);
    assert.equal((await call('anita', 'GET', '/setup')).data.steps.find((s: any) => s.key === 'roles').done, true);

    // Skyline never sees it, can't edit it and can't assign it.
    assert.equal((await call('vikram', 'GET', '/roles')).data.some((r: any) => r.name === 'Site Manager'), false);
    assert.equal((await call('vikram', 'PATCH', `/roles/${created.data.id}`, input)).status, 404);
    assert.equal((await call('vikram', 'PATCH', `/users/${id('vikram')}`, { roleIds: [created.data.id] })).status, 404);

    // Eastfield assigns and edits it; default roles can't be edited or deleted.
    const rahul = await call('anita', 'PATCH', `/users/${id('rahul')}`, { roleIds: [created.data.id] });
    assert.deepEqual(rahul.data.roles, ['Site Manager']);
    const edited = await call('anita', 'PATCH', `/roles/${created.data.id}`, { ...input, name: 'Tower Manager', scope: 'all' });
    assert.equal(edited.data.name, 'Tower Manager');
    assert.equal(edited.data.scope, 'all');
    assert.equal((await call('anita', 'PATCH', `/roles/${engineer.id}`, input)).status, 409);
    assert.equal((await call('anita', 'DELETE', `/roles/${engineer.id}`)).status, 409);
  });

  test('deleting a custom role moves its people to another role', async () => {
    const roles = (await call('anita', 'GET', '/roles')).data;
    const viewer = roles.find((r: any) => r.name === 'Viewer');
    const role = (await call('anita', 'POST', '/roles', {
      name: 'Auditor', description: '', scope: 'all', permissions: ['project.projects.view'], modules: [],
    })).data;
    await call('anita', 'PATCH', `/users/${id('rahul')}`, { roleIds: [role.id] });

    const missing = await call('anita', 'DELETE', `/roles/${role.id}`);
    assert.equal(missing.status, 400);
    assert.match(missing.body.message, /1 people have this role/);
    assert.equal((await call('anita', 'DELETE', `/roles/${role.id}?reassignTo=${viewer.id}`)).status, 200);
    assert.deepEqual((await call('anita', 'GET', `/users/${id('rahul')}`)).data.roles, ['Viewer']);
  });

  test('module access is per organization and changes effective permissions', async () => {
    const roles = (await call('anita', 'GET', '/roles')).data;
    const roleId = (name: string) => roles.find((r: any) => r.name === name).id;
    const me = async (who: string): Promise<any> => {
      const res = await fetch(`${baseURL}/api/v1/auth/me`, { headers: { Authorization: `Bearer token-${who}` } });
      return ((await res.json()) as any).data;
    };
    assert.ok((await me('rahul')).user.permissions.includes('planning.wbs.view'));

    // Planning off for Site Engineer; Organization Admin can't be restricted; unsubscribed modules are ignored.
    const saved = await call('anita', 'PUT', '/module-access', {
      [roleId('Site Engineer')]: ['tasks'],
      [roleId('Organization Admin')]: [],
      [roleId('Viewer')]: ['planning', 'finance'],
    });
    assert.equal(saved.status, 200);
    const after = await me('rahul');
    assert.equal(after.user.permissions.includes('planning.wbs.view'), false);
    assert.equal(after.user.modules.includes('planning'), false);
    assert.ok((await me('anita')).user.permissions.includes('planning.wbs.manage'));
    assert.deepEqual(saved.data.find((r: any) => r.name === 'Viewer').modules.sort(), ['planning', 'project_core']);

    // Skyline's Site Engineer role is untouched.
    const skyline = (await call('vikram', 'GET', '/roles')).data.find((r: any) => r.name === 'Site Engineer');
    assert.ok(skyline.modules.includes('planning'));
    assert.equal((await call('rahul', 'PUT', '/module-access', { [roleId('Viewer')]: [] })).status, 403);
  });

  test('invites: validation, one account per email, seat limit and the Mailgun email', async () => {
    const roles = (await call('anita', 'GET', '/roles')).data;
    const engineer = roles.find((r: any) => r.name === 'Site Engineer').id;
    const ef01 = (await callApi('anita', 'GET', '/projects')).data.find((p: any) => p.code === 'EF-01').id;
    const invite = (emails: string[]) => call('anita', 'POST', '/invites', { emails, roleIds: [engineer], projectIds: [ef01] });

    assert.equal((await invite(['not-an-email'])).status, 400);
    assert.match((await invite(['rahul@example.com'])).body.message, /Already in your organization/);
    assert.match((await invite(['vikram@example.com'])).body.message, /Already has a BlenAxis account/);
    // Eastfield has 3 of 4 seats in use.
    const full = await invite(['a@site.example', 'b@site.example']);
    assert.equal(full.status, 422);
    assert.equal(full.body.code, 'seat_limit');

    const ok = await invite(['imran.shaikh@site.example']);
    assert.equal(ok.status, 201);
    const [imran] = ok.data.invited;
    assert.equal(imran.status, 'invited');
    assert.equal(imran.name, 'Imran Shaikh');
    assert.match(imran.inviteUrl, /^\/invite\/.+/);
    assert.deepEqual(imran.roles, ['Site Engineer']);
    assert.equal((await call('anita', 'GET', '/subscription')).data.usage.maxUsers, 4, 'invited people take a seat');
    assert.deepEqual((await call('anita', 'GET', '/users?status=invited')).data.items.map((u: any) => u.email), ['imran.shaikh@site.example']);

    assert.equal(mails.length, 1);
    assert.equal(mails[0].template, 'blenaxis-invite');
    assert.equal(mails[0].to, 'imran.shaikh@site.example');
    assert.equal(mails[0].variables.inviteUrl, `https://app.example.com${imran.inviteUrl}`);
    assert.equal(mails[0].variables.organizationName, 'Eastfield Developers');
    assert.equal(mails[0].variables.invitedByName, 'anita');
  });

  test('accepting an invite creates the Firebase account and signs the person in; links are single use', async () => {
    const roles = (await call('anita', 'GET', '/roles')).data;
    const qs = roles.find((r: any) => r.name === 'QS').id;
    const ef01 = (await callApi('anita', 'GET', '/projects')).data.find((p: any) => p.code === 'EF-01').id;
    const [invited] = (await call('anita', 'POST', '/invites', { emails: ['priya@example.org'], roleIds: [qs], projectIds: [ef01] })).data.invited;
    const token = invited.inviteUrl.split('/').pop();
    const publicCall = async (method: string, path: string, body?: unknown) => {
      const res = await fetch(`${baseURL}/api/v1/invites${path}`, {
        method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined,
      });
      return { status: res.status, headers: res.headers, body: (await res.json()) as any };
    };

    // Signing in before accepting explains what to do.
    const early = await fetch(`${baseURL}/api/v1/auth/login`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: 'priya@example.org', password: 'Whatever1' }),
    });
    assert.match(((await early.json()) as any).message, /Accept your invite first/);

    const details = await publicCall('GET', `/${token}`);
    assert.equal(details.status, 200);
    assert.deepEqual(details.body.data.roles, ['QS']);
    assert.equal(details.body.data.organizationName, 'Eastfield Developers');

    assert.equal((await publicCall('POST', `/${token}/accept`, { name: 'Priya Nair', password: 'short' })).status, 400);
    const accepted = await publicCall('POST', `/${token}/accept`, { name: 'Priya Nair', password: 'Str0ngPass' });
    assert.equal(accepted.status, 200);
    assert.equal(accepted.headers.get('x-id-token'), 'id-priya@example.org');
    assert.equal(accepted.body.data.tenant.slug, 'eastfield');
    assert.deepEqual(accepted.body.data.user.roles, ['QS']);
    assert.deepEqual(firebaseAccounts, ['priya@example.org']);

    assert.equal((await publicCall('GET', `/${token}`)).status, 410);
    assert.equal((await publicCall('POST', `/${token}/accept`, { name: 'Priya', password: 'Str0ngPass' })).status, 410);
    const priya = (await call('anita', 'GET', `/users?search=priya`)).data.items[0];
    assert.equal(priya.status, 'active');
    assert.equal(priya.name, 'Priya Nair');
  });

  test('resend replaces the link, expiry and deactivation stop it, reset password emails a Firebase link', async () => {
    const roles = (await call('anita', 'GET', '/roles')).data;
    const viewer = roles.find((r: any) => r.name === 'Viewer').id;
    const ef01 = (await callApi('anita', 'GET', '/projects')).data.find((p: any) => p.code === 'EF-01').id;
    const [invited] = (await call('anita', 'POST', '/invites', { emails: ['sam@site.example'], roleIds: [viewer], projectIds: [ef01] })).data.invited;
    const oldToken = invited.inviteUrl.split('/').pop();
    const status = async (token: string) => (await fetch(`${baseURL}/api/v1/invites/${token}`)).status;

    const resent = await call('anita', 'POST', `/users/${invited.id}/invite`);
    const newToken = resent.data.inviteUrl.split('/').pop();
    assert.notEqual(newToken, oldToken);
    assert.equal(await status(oldToken), 410);
    assert.equal(await status(newToken), 200);
    assert.equal(mails.at(-1)!.template, 'blenaxis-invite');

    await prisma.user.updateMany({ where: { email: 'sam@site.example' }, data: { invitedAt: new Date(Date.now() - 8 * 86_400_000) } });
    assert.equal(await status(newToken), 410, 'expired after 7 days');

    await call('anita', 'PATCH', `/users/${invited.id}/status`, { status: 'disabled' });
    const back = await call('anita', 'PATCH', `/users/${invited.id}/status`, { status: 'active' });
    assert.equal(back.data.status, 'invited', 'someone who never accepted goes back to invited');
    assert.equal(await status(back.data.inviteUrl.split('/').pop()), 200);

    assert.equal((await call('anita', 'POST', `/users/${invited.id}/reset-password`)).status, 409);
    const reset = await call('anita', 'POST', `/users/${id('rahul')}/reset-password`);
    assert.equal(reset.status, 202);
    assert.match(reset.data.message, /reset link sent/i, 'the app shows data.message');
    assert.equal(mails.at(-1)!.template, 'blenaxis-password-reset');
    assert.equal(mails.at(-1)!.variables.resetUrl, 'https://reset.example.com/?email=rahul@example.com');
    assert.equal((await call('rahul', 'POST', `/invites`, { emails: ['x@site.example'], roleIds: [viewer], projectIds: [] })).status, 403);
  });

  test('project access: add, limit to sites, change and remove; tenant isolation', async () => {
    const access = (await call('anita', 'GET', '/project-access')).data;
    const ef01 = access.find((p: any) => p.code === 'EF-01');
    const rs02 = access.find((p: any) => p.code === 'RS-02');
    assert.deepEqual(ef01.sites.map((s: any) => s.name), ['Tower A', 'Tower B', 'Tower C']);
    const towerA = ef01.sites[0].id;

    // A site from another project, or another organization's person, is not found.
    assert.equal((await call('anita', 'POST', `/project-access/${ef01.projectId}/members`, { userIds: [id('rahul')], projectRole: 'Site Engineer', siteIds: [rs02.sites[0].id] })).status, 404);
    assert.equal((await call('anita', 'POST', `/project-access/${ef01.projectId}/members`, { userIds: [id('vikram')], projectRole: 'Site Engineer' })).status, 404);
    assert.equal((await call('anita', 'POST', `/project-access/${ef01.projectId}/members`, { userIds: [id('rahul')], projectRole: 'Captain' })).status, 400);

    const added = await call('anita', 'POST', `/project-access/${ef01.projectId}/members`, { userIds: [id('rahul')], projectRole: 'Site Engineer', siteIds: [towerA] });
    assert.equal(added.status, 201);
    assert.deepEqual(added.data.members.map((m: any) => [m.name, m.projectRole, m.siteIds]), [['rahul', 'Site Engineer', [towerA]]]);
    assert.deepEqual(added.data.members[0].orgRoles, ['Site Engineer']);

    const rahul = (await call('anita', 'GET', `/users/${id('rahul')}`)).data;
    assert.deepEqual(rahul.projects.map((p: any) => [p.projectCode, p.projectRole]), [['EF-01', 'Site Engineer']]);
    assert.equal((await call('anita', 'GET', '/setup')).data.steps.find((s: any) => s.key === 'projects').done, true);
    assert.equal((await call('anita', 'GET', '/subscription')).data.usage.maxProjects, 2);

    const changed = await call('anita', 'PATCH', `/project-access/${ef01.projectId}/members/${id('rahul')}`, { projectRole: 'QS', siteIds: [] });
    assert.deepEqual(changed.data.members[0].siteIds, [], 'empty = every site');
    assert.equal((await call('vikram', 'PATCH', `/project-access/${ef01.projectId}/members/${id('rahul')}`, { projectRole: 'QS' })).status, 404);
    assert.deepEqual((await call('vikram', 'GET', '/project-access')).data, []);
    assert.equal((await call('rahul', 'GET', '/project-access')).status, 403);

    assert.equal((await call('anita', 'DELETE', `/project-access/${ef01.projectId}/members/${id('rahul')}`)).status, 200);
    assert.equal((await call('anita', 'DELETE', `/project-access/${ef01.projectId}/members/${id('rahul')}`)).status, 404);
  });

  test('project scope: assigned-projects roles only see their own projects', async () => {
    const codes = async (who: string) => (await callApi(who, 'GET', '/projects')).data.map((p: any) => p.code);
    assert.deepEqual(await codes('anita'), ['EF-01', 'RS-02'], 'Organization Admin sees every project');
    assert.deepEqual(await codes('rahul'), [], 'a Site Engineer on no project sees none');
    const ef01 = (await call('anita', 'GET', '/project-access')).data.find((p: any) => p.code === 'EF-01').projectId;
    await call('anita', 'POST', `/project-access/${ef01}/members`, { userIds: [id('rahul')], projectRole: 'Site Engineer' });
    assert.deepEqual(await codes('rahul'), ['EF-01']);
    assert.deepEqual(await codes('vikram'), [], 'another organization sees none of them');
  });

  test('projects & sites: create within the plan limit, edit sites, guard rails and tenant isolation', async () => {
    await prisma.organization.update({ where: { id: ids.eastfield }, data: { maxProjects: 3 } });
    assert.equal((await call('rahul', 'GET', '/projects')).status, 403, 'a Site Engineer cannot set up projects');
    assert.equal((await call('anita', 'POST', '/projects', { name: 'Lake View', code: 'bad code' })).status, 400);
    assert.equal((await call('anita', 'POST', '/projects', { name: 'Lake View', code: 'EF-01' })).status, 409, 'codes are unique');
    assert.equal((await call('anita', 'POST', '/projects', { name: 'Lake View', code: 'LV-1', sites: [{ name: 'A' }, { name: 'a' }] })).status, 400);

    const created = await call('anita', 'POST', '/projects', { name: 'Lake View', code: 'lv-1', location: 'Pune', sites: [{ name: 'Tower A' }, { name: 'Tower B' }] });
    assert.equal(created.status, 201);
    assert.equal(created.data.code, 'LV-1');
    assert.deepEqual(created.data.sites.map((s: any) => s.name), ['Tower A', 'Tower B']);
    assert.equal((await call('anita', 'POST', '/projects', { name: 'One too many', code: 'X-1' })).status, 409, 'plan allows 3 projects');
    const listed = (await call('anita', 'GET', '/projects')).data.items.map((p: any) => p.code);
    assert.deepEqual(listed, ['EF-01', 'LV-1', 'RS-02']);
    // Search covers name, code, location and site names; the people filter spots projects nobody is on.
    const search = async (q: string) => (await call('anita', 'GET', `/projects?${q}`)).data.items.map((p: any) => p.code);
    assert.deepEqual(await search('search=kharadi'), ['RS-02'], 'location');
    assert.deepEqual(await search('search=lake'), ['LV-1'], 'name');
    assert.deepEqual(await search('search=tower%20b'), ['EF-01', 'LV-1'], 'site name');
    // The project-scope test above left rahul on EF-01.
    assert.deepEqual(await search('people=with'), ['EF-01']);
    assert.deepEqual(await search('people=without'), ['LV-1', 'RS-02']);
    const paged = (await call('anita', 'GET', '/projects?page=2&limit=2')).data;
    assert.deepEqual([paged.items.map((p: any) => p.code), paged.pagination.total], [['RS-02'], 3]);
    assert.ok((await call('anita', 'GET', '/project-access')).data.some((p: any) => p.code === 'LV-1'), 'shows up in Project access');

    // Rename one site, add one, remove none; then limit someone to Tower A and try to remove it.
    const [towerA, towerB] = created.data.sites;
    const edited = await call('anita', 'PUT', `/projects/${created.data.id}`, {
      name: 'Lake View Residences', code: 'LV-1', sites: [{ id: towerA.id, name: 'Tower A' }, { id: towerB.id, name: 'Clubhouse' }, { name: 'Tower C' }],
    });
    assert.equal(edited.status, 200);
    assert.deepEqual(edited.data.sites.map((s: any) => s.name), ['Clubhouse', 'Tower A', 'Tower C']);
    await call('anita', 'POST', `/project-access/${created.data.id}/members`, { userIds: [id('rahul')], projectRole: 'Site Engineer', siteIds: [towerA.id] });
    const blocked = await call('anita', 'PUT', `/projects/${created.data.id}`, { name: 'Lake View Residences', code: 'LV-1', sites: [{ id: towerB.id, name: 'Clubhouse' }] });
    assert.equal(blocked.status, 409, 'removing a site someone is limited to would widen their access');

    // Another organization can neither see nor change it.
    assert.deepEqual((await call('vikram', 'GET', '/projects')).data.items, []);
    assert.equal((await call('vikram', 'PUT', `/projects/${created.data.id}`, { name: 'Hijacked', code: 'LV-1' })).status, 404);
    assert.equal((await call('vikram', 'DELETE', `/projects/${created.data.id}`)).status, 404);

    assert.equal((await call('anita', 'DELETE', `/projects/${created.data.id}`)).status, 200);
    assert.equal((await call('anita', 'GET', '/projects')).data.pagination.total, 2);
  });

  test('invites with projects: required for assigned roles once projects exist, and create memberships', async () => {
    await prisma.organization.update({ where: { id: ids.eastfield }, data: { maxUsers: 10 } });
    const roles = (await call('anita', 'GET', '/roles')).data;
    const engineer = roles.find((r: any) => r.name === 'Site Engineer').id;
    const director = roles.find((r: any) => r.name === 'Director').id;
    const ef01 = (await callApi('anita', 'GET', '/projects')).data.find((p: any) => p.code === 'EF-01').id;

    const missing = await call('anita', 'POST', '/invites', { emails: ['nia@example.org'], roleIds: [engineer], projectIds: [] });
    assert.equal(missing.status, 400);
    assert.match(missing.body.message, /pick at least one project/);
    // An "all projects" role doesn't need one.
    assert.equal((await call('anita', 'POST', '/invites', { emails: ['dev@example.org'], roleIds: [director], projectIds: [] })).status, 201);

    const [nia] = (await call('anita', 'POST', '/invites', { emails: ['nia@example.org'], roleIds: [engineer], projectIds: [ef01] })).data.invited;
    assert.deepEqual(nia.projects.map((p: any) => [p.projectCode, p.projectRole]), [['EF-01', 'Site Engineer']]);
  });

  test('access requests are stored and emailed to the organization admins', async () => {
    assert.equal((await callApi('rahul', 'POST', '/access-requests', {})).status, 400);
    const sent = await callApi('rahul', 'POST', '/access-requests', { module: 'tenant_admin', path: '/admin/users', message: 'Need to see my team' });
    assert.equal(sent.status, 202);
    assert.match(sent.data.message, /notified/, 'the app shows data.message');
    const emails = mails.filter((m) => m.template === 'blenaxis-access-request');
    assert.deepEqual(emails.map((m) => m.to).sort(), ['anita@example.com', 'karan@example.com']);
    assert.equal(emails[0].variables.what, 'Administration');
    assert.equal(emails[0].variables.pageUrl, 'https://app.example.com/admin/users');
    assert.equal(await prisma.accessRequest.count(), 1);
  });

  test('deactivating frees a seat; reactivating respects the seat limit', async () => {
    assert.equal((await call('anita', 'PATCH', `/users/${id('anita')}/status`, { status: 'disabled' })).status, 409);

    const off = await call('anita', 'PATCH', `/users/${id('rahul')}/status`, { status: 'disabled' });
    assert.equal(off.data.status, 'disabled');
    assert.equal((await call('anita', 'GET', '/subscription')).data.usage.maxUsers, 2);

    // Fill the plan (4 seats), then reactivating Rahul hits the limit.
    await prisma.user.createMany({
      data: [
        { name: 'x1', email: 'x1@example.com', firebaseUid: 'uid-x1', organizationId: ids.eastfield },
        { name: 'x2', email: 'x2@example.com', firebaseUid: 'uid-x2', organizationId: ids.eastfield },
      ],
    });
    const full = await call('anita', 'PATCH', `/users/${id('rahul')}/status`, { status: 'active' });
    assert.equal(full.status, 422);
    assert.equal(full.body.code, 'seat_limit');
  });
});
