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

process.env.DATABASE_URL = 'postgresql://postgres:test@localhost:5432/blenaxis';
process.env.NODE_ENV = 'test';
// Never used for a real token exchange below (verifyIdToken is stubbed), just
// needs to satisfy env validation and firebase-admin's cert() shape.
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.DOCS_USERNAME = 'test-docs-user';
process.env.DOCS_PASSWORD = 'test-docs-password';

const { default: app } = await import('../src/app.js');
const { default: prisma } = await import('../src/config/db.js');
const { firebaseAuth } = await import('../src/config/firebase.js');
const { encodeId } = await import('../src/utils/idCodec.js');

let server: Server;
let baseURL: string;

// GET /api/v1/users is admin-only, so these tests need a fake authenticated
// admin/user identity, resolved the same way verifyFirebaseToken does.
const ADMIN_ROLE = { id: 1, name: 'admin', description: null, isSystem: true, createdAt: new Date(), updatedAt: new Date() };
const USER_ROLE = { id: 2, name: 'user', description: null, isSystem: true, createdAt: new Date(), updatedAt: new Date() };

const usersByFirebaseUid = new Map<string, any>([
  ['admin-uid', { id: 1, name: 'Admin', email: 'admin@example.com', firebaseUid: 'admin-uid', avatarUrl: null, emailVerified: true, isActive: true, roleId: 1, role: ADMIN_ROLE, createdAt: new Date(), updatedAt: new Date() }],
  ['user-uid', { id: 2, name: 'Regular', email: 'regular@example.com', firebaseUid: 'user-uid', avatarUrl: null, emailVerified: true, isActive: true, roleId: 2, role: USER_ROLE, createdAt: new Date(), updatedAt: new Date() }],
]);

const tokenClaims = new Map<string, any>([
  ['admin-token', { uid: 'admin-uid' }],
  ['user-token', { uid: 'user-uid' }],
]);

const originalFindUnique = prisma.user.findUnique;
const originalUpdate = prisma.user.update;
const originalVerifyIdToken = firebaseAuth.verifyIdToken;

before(async () => {
  prisma.user.findUnique = (async ({ where }: any) => usersByFirebaseUid.get(where.firebaseUid) ?? null) as unknown as typeof prisma.user.findUnique;
  prisma.user.update = (async ({ where }: any) => [...usersByFirebaseUid.values()].find((user) => user.id === where.id)) as unknown as typeof prisma.user.update;
  firebaseAuth.verifyIdToken = (async (idToken: string) => {
    const claims = tokenClaims.get(idToken);
    if (!claims) throw new Error('invalid token');
    return claims;
  }) as unknown as typeof firebaseAuth.verifyIdToken;

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

after(async () => {
  prisma.user.findUnique = originalFindUnique;
  prisma.user.update = originalUpdate;
  firebaseAuth.verifyIdToken = originalVerifyIdToken;
  await new Promise<void>((resolve) => server.close(() => resolve()));
  await prisma.$disconnect();
});

// Prisma uses proxy methods, so replace them directly for these HTTP tests.
// Prisma's client types are too complex to stub against generically, so this
// helper works in terms of `any` on purpose.
function stubMethod(t: TestContext, target: Record<string, any>, key: string, implementation: (...args: any[]) => any) {
  const original = target[key];
  target[key] = implementation;
  t.after(() => { target[key] = original; });
  return { mock: { mockImplementation(fn: (...args: any[]) => any) { target[key] = fn; } } };
}

function withToken(idToken: string, init: RequestInit = {}) {
  return { ...init, headers: { ...init.headers, Authorization: `Bearer ${idToken}` } };
}

async function json(response: Response): Promise<any> {
  return response.json();
}

function patchJson(path: string, body: unknown, init: RequestInit = {}) {
  return fetch(`${baseURL}${path}`, {
    ...init,
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json', ...init.headers },
    body: JSON.stringify(body),
  });
}

function postJson(path: string, body: unknown, init: RequestInit = {}) {
  return fetch(`${baseURL}${path}`, {
    ...init,
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...init.headers },
    body: JSON.stringify(body),
  });
}

function putJson(path: string, body: unknown, init: RequestInit = {}) {
  return fetch(`${baseURL}${path}`, {
    ...init,
    method: 'PUT',
    headers: { 'Content-Type': 'application/json', ...init.headers },
    body: JSON.stringify(body),
  });
}

test('GET /api/v1/users requires authentication', async () => {
  const response = await fetch(`${baseURL}/api/v1/users`);
  assert.equal(response.status, 401);
});

test('GET /api/v1/users rejects a non-admin role', async () => {
  const response = await fetch(`${baseURL}/api/v1/users`, withToken('user-token'));
  assert.equal(response.status, 403);
});

test('GET /api/v1/users returns a paginated list for an admin', async (t) => {
  stubMethod(t, prisma.user, 'findMany', async () => []);
  stubMethod(t, prisma.user, 'count', async () => 0);
  const response = await fetch(`${baseURL}/api/v1/users`, withToken('admin-token'));
  assert.equal(response.status, 200);
  assert.deepEqual(await json(response), {
    success: true,
    message: 'Users fetched successfully',
    data: { items: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 } },
  });
});

test('DELETE /api/v1/users is a known path with the wrong method: 405, not 404', async () => {
  const response = await fetch(`${baseURL}/api/v1/users`, withToken('admin-token', { method: 'DELETE' }));
  assert.equal(response.status, 405);
  assert.equal(response.headers.get('allow'), 'GET');
});

test('PATCH /api/v1/users/:id/role rejects a non-admin role', async () => {
  const response = await patchJson(`/api/v1/users/${encodeId(3)}/role`, { roleId: 5 }, withToken('user-token'));
  assert.equal(response.status, 403);
});

test('PATCH /api/v1/users/:id/role rejects a missing roleId', async () => {
  const response = await patchJson(`/api/v1/users/${encodeId(3)}/role`, {}, withToken('admin-token'));
  assert.equal(response.status, 400);
});

test('PATCH /api/v1/users/:id/role updates the role for an admin', async (t) => {
  const updatedRole = { id: 5, name: 'site-engineer', description: null, isSystem: false, createdAt: new Date(), updatedAt: new Date() };
  stubMethod(t, prisma.user, 'update', async ({ where, data }: any) => ({
    id: where.id, name: 'Target User', email: 'target@example.com', roleId: data.roleId, role: updatedRole,
  }));
  const response = await patchJson(`/api/v1/users/${encodeId(3)}/role`, { roleId: 5 }, withToken('admin-token'));
  assert.equal(response.status, 200);
  const payload = await json(response);
  assert.equal(payload.data.role.name, 'site-engineer');
});

test('PATCH /api/v1/users/:id/role rejects a roleId that does not exist', async (t) => {
  stubMethod(t, prisma.user, 'update', async () => {
    throw Object.assign(new Error('Foreign key constraint failed'), { code: 'P2003' });
  });
  const response = await patchJson(`/api/v1/users/${encodeId(3)}/role`, { roleId: 999 }, withToken('admin-token'));
  assert.equal(response.status, 400);
  assert.equal((await json(response)).message, 'Referenced record does not exist');
});

test('PATCH /api/v1/users/:id/role 404s for a user that does not exist', async (t) => {
  stubMethod(t, prisma.user, 'update', async () => {
    throw Object.assign(new Error('Record to update not found'), { code: 'P2025' });
  });
  const response = await patchJson(`/api/v1/users/${encodeId(999)}/role`, { roleId: 5 }, withToken('admin-token'));
  assert.equal(response.status, 404);
});

test('PATCH /api/v1/users/:id/organization rejects a non-admin role', async () => {
  const response = await patchJson(`/api/v1/users/${encodeId(3)}/organization`, { organizationId: 1 }, withToken('user-token'));
  assert.equal(response.status, 403);
});

test('PATCH /api/v1/users/:id/organization assigns an org for an admin', async (t) => {
  const org = { id: 1, name: 'Expertinasia Apps', slug: 'eia-ems', status: 'active', createdAt: new Date(), updatedAt: new Date() };
  stubMethod(t, prisma.user, 'update', async ({ where, data }: any) => ({
    id: where.id, name: 'Target User', email: 'target@example.com', organizationId: data.organizationId, organization: org,
  }));
  const response = await patchJson(`/api/v1/users/${encodeId(3)}/organization`, { organizationId: 1 }, withToken('admin-token'));
  assert.equal(response.status, 200);
  assert.equal((await json(response)).data.organization.slug, 'eia-ems');
});

test('PATCH /api/v1/users/:id/organization rejects an organizationId that does not exist', async (t) => {
  stubMethod(t, prisma.user, 'update', async () => {
    throw Object.assign(new Error('Foreign key constraint failed'), { code: 'P2003' });
  });
  const response = await patchJson(`/api/v1/users/${encodeId(3)}/organization`, { organizationId: 999 }, withToken('admin-token'));
  assert.equal(response.status, 400);
});

test('requirePermission: roleHasPermission checks role_permissions directly', async (t) => {
  const { default: requirePermission } = await import('../src/middlewares/requirePermission.js');
  stubMethod(t, prisma.rolePermission, 'count', async ({ where }: any) =>
    (where.role.name === 'admin' && where.permission.name === 'units.create') ? 1 : 0);

  const allowed: any[] = [];
  const denied: any[] = [];
  const fakeRes = { status: () => ({ json: () => {} }) } as any;

  await requirePermission('units.create')(
    { user: { sub: '1', firebaseUid: 'x', role: 'admin', organizationId: null } } as any,
    fakeRes,
    (err?: unknown) => allowed.push(err),
  );
  assert.equal(allowed[0], undefined); // next() called with no error → allowed

  await requirePermission('units.create')(
    { user: { sub: '2', firebaseUid: 'y', role: 'user', organizationId: null } } as any,
    { status: (code: number) => { denied.push(code); return { json: () => {} }; } } as any,
    () => { throw new Error('next() should not be called when denied'); },
  );
  assert.deepEqual(denied, [403]);
});

test('GET /api/v1/units requires authentication but not admin', async (t) => {
  const noAuth = await fetch(`${baseURL}/api/v1/units`);
  assert.equal(noAuth.status, 401);

  stubMethod(t, prisma.unit, 'findMany', async () => [{ id: 1, name: 'Kilogram', symbol: 'kg' }]);
  stubMethod(t, prisma.unit, 'count', async () => 1);
  const asRegularUser = await fetch(`${baseURL}/api/v1/units`, withToken('user-token'));
  assert.equal(asRegularUser.status, 200);
});

test('POST /api/v1/units requires admin even though GET does not', async (t) => {
  const asRegularUser = await postJson('/api/v1/units', { name: 'Bag', symbol: 'bag' }, withToken('user-token'));
  assert.equal(asRegularUser.status, 403);

  stubMethod(t, prisma.unit, 'create', async ({ data }: any) => ({ id: 2, ...data }));
  const asAdmin = await postJson('/api/v1/units', { name: 'Bag', symbol: 'bag' }, withToken('admin-token'));
  assert.equal(asAdmin.status, 201);
  assert.equal((await json(asAdmin)).data.symbol, 'bag');
});

test('DELETE /api/v1/units/:id is a known path with the wrong method: 405, not 404', async () => {
  const response = await fetch(`${baseURL}/api/v1/units`, withToken('user-token', { method: 'DELETE' }));
  assert.equal(response.status, 405);
  assert.equal(response.headers.get('allow'), 'GET, POST');
});

test('GET /api/v1/cost-codes requires authentication but not admin', async (t) => {
  const noAuth = await fetch(`${baseURL}/api/v1/cost-codes`);
  assert.equal(noAuth.status, 401);

  stubMethod(t, prisma.costCode, 'findMany', async () => [{ id: 1, code: 'CIV-001', name: 'Civil - Foundation', category: 'Civil', description: null }]);
  stubMethod(t, prisma.costCode, 'count', async () => 1);
  const asRegularUser = await fetch(`${baseURL}/api/v1/cost-codes`, withToken('user-token'));
  assert.equal(asRegularUser.status, 200);
});

test('POST /api/v1/cost-codes requires admin even though GET does not', async (t) => {
  const asRegularUser = await postJson('/api/v1/cost-codes', { code: 'MEP-001', name: 'MEP - Wiring' }, withToken('user-token'));
  assert.equal(asRegularUser.status, 403);

  stubMethod(t, prisma.costCode, 'create', async ({ data }: any) => ({ id: 2, ...data }));
  const asAdmin = await postJson('/api/v1/cost-codes', { code: 'MEP-001', name: 'MEP - Wiring', category: 'MEP' }, withToken('admin-token'));
  assert.equal(asAdmin.status, 201);
  assert.equal((await json(asAdmin)).data.code, 'MEP-001');
});

test('DELETE /api/v1/cost-codes is a known path with the wrong method: 405, not 404', async () => {
  const response = await fetch(`${baseURL}/api/v1/cost-codes`, withToken('user-token', { method: 'DELETE' }));
  assert.equal(response.status, 405);
  assert.equal(response.headers.get('allow'), 'GET, POST');
});

const MASTER_RESOURCES = [
  { path: 'materials', label: 'Material', model: 'material' as const, body: { name: 'Cement OPC 53', unitId: 1 } },
  { path: 'equipment', label: 'Equipment', model: 'equipment' as const, body: { name: 'Excavator', unitId: 1 } },
  { path: 'labours', label: 'Labour', model: 'labour' as const, body: { name: 'Mason', unitId: 1 } },
];

for (const resource of MASTER_RESOURCES) {
  test(`GET /api/v1/${resource.path} requires authentication but not admin`, async (t) => {
    const noAuth = await fetch(`${baseURL}/api/v1/${resource.path}`);
    assert.equal(noAuth.status, 401);

    stubMethod(t, (prisma as any)[resource.model], 'findMany', async () => [{ id: 1, ...resource.body, unit: { id: 1, name: 'Kilogram', symbol: 'kg' } }]);
    stubMethod(t, (prisma as any)[resource.model], 'count', async () => 1);
    const asRegularUser = await fetch(`${baseURL}/api/v1/${resource.path}`, withToken('user-token'));
    assert.equal(asRegularUser.status, 200);
  });

  test(`POST /api/v1/${resource.path} requires admin even though GET does not`, async (t) => {
    const asRegularUser = await postJson(`/api/v1/${resource.path}`, resource.body, withToken('user-token'));
    assert.equal(asRegularUser.status, 403);

    stubMethod(t, (prisma as any)[resource.model], 'create', async ({ data }: any) => ({ id: 2, ...data, unit: { id: 1, name: 'Kilogram', symbol: 'kg' } }));
    const asAdmin = await postJson(`/api/v1/${resource.path}`, resource.body, withToken('admin-token'));
    assert.equal(asAdmin.status, 201);
    assert.equal((await json(asAdmin)).data.name, resource.body.name);
  });

  test(`POST /api/v1/${resource.path} rejects a unitId that does not exist`, async (t) => {
    stubMethod(t, (prisma as any)[resource.model], 'create', async () => {
      throw Object.assign(new Error('Foreign key constraint failed'), { code: 'P2003' });
    });
    const response = await postJson(`/api/v1/${resource.path}`, { ...resource.body, unitId: 999 }, withToken('admin-token'));
    assert.equal(response.status, 400);
  });
}

const OPEN_MASTER_RESOURCES = [
  { path: 'contractors', model: 'contractor' as const, body: { name: 'ABC Constructions' } },
  { path: 'vendors', model: 'vendor' as const, body: { name: 'XYZ Traders' } },
];

for (const resource of OPEN_MASTER_RESOURCES) {
  test(`GET /api/v1/${resource.path} requires authentication but not admin`, async (t) => {
    const noAuth = await fetch(`${baseURL}/api/v1/${resource.path}`);
    assert.equal(noAuth.status, 401);

    stubMethod(t, (prisma as any)[resource.model], 'findMany', async () => [{ id: 1, ...resource.body }]);
    stubMethod(t, (prisma as any)[resource.model], 'count', async () => 1);
    const asRegularUser = await fetch(`${baseURL}/api/v1/${resource.path}`, withToken('user-token'));
    assert.equal(asRegularUser.status, 200);
  });

  test(`POST /api/v1/${resource.path} requires admin even though GET does not`, async (t) => {
    const asRegularUser = await postJson(`/api/v1/${resource.path}`, resource.body, withToken('user-token'));
    assert.equal(asRegularUser.status, 403);

    stubMethod(t, (prisma as any)[resource.model], 'create', async ({ data }: any) => ({ id: 2, ...data }));
    const asAdmin = await postJson(`/api/v1/${resource.path}`, resource.body, withToken('admin-token'));
    assert.equal(asAdmin.status, 201);
    assert.equal((await json(asAdmin)).data.name, resource.body.name);
  });
}

test('GET /api/v1/organizations is admin-only end to end, unlike the operational masters', async (t) => {
  const noAuth = await fetch(`${baseURL}/api/v1/organizations`);
  assert.equal(noAuth.status, 401);

  const asRegularUser = await fetch(`${baseURL}/api/v1/organizations`, withToken('user-token'));
  assert.equal(asRegularUser.status, 403);

  stubMethod(t, prisma.organization, 'findMany', async () => []);
  stubMethod(t, prisma.organization, 'count', async () => 0);
  stubMethod(t, prisma.organization, 'groupBy', async () => []);
  const asAdmin = await fetch(`${baseURL}/api/v1/organizations`, withToken('admin-token'));
  assert.equal(asAdmin.status, 200);
  const payload = await json(asAdmin);
  assert.ok('statusCounts' in payload.data);
});

// blenaxis-super-admin's full onboarding wizard payload (Organization, Plan,
// Modules, Limits, Tenant Admin, Review & Activate — see CreateOrganizationRequest).
const WIZARD_PAYLOAD = {
  name: 'Expertinasia Apps', slug: 'eia-ems', contactEmail: 'admin@eia-ems.dev',
  country: 'India', planId: 'plan_starter',
  moduleKeys: ['tasks'], // core modules are unioned in server-side
  limits: { maxUsers: 25, maxProjects: 3, storageGb: 50 },
  tenantAdmin: { name: 'Asdfg', email: 'sdf@yopmail.com' },
  activateNow: true,
};

test('POST /api/v1/organizations onboards a tenant, unions core modules, and creates a tenant-admin invite', async (t) => {
  stubMethod(t, prisma.plan, 'findUnique', async () => ({ id: 'plan_starter', priceMonthly: 24999, currency: 'INR' }));
  stubMethod(t, prisma.platformSettings, 'upsert', async () => ({ id: 1, defaultTrialDays: 14 }));
  stubMethod(t, prisma.organization, 'create', async ({ data }: any) => ({ id: 1, ...data, plan: { name: 'Starter' } }));
  stubMethod(t, prisma.organizationInvite, 'create', async ({ data }: any) => ({ id: 1, ...data }));
  stubMethod(t, prisma.organizationInvite, 'findFirst', async () => null);
  stubMethod(t, prisma.user, 'findFirst', async () => null);
  stubMethod(t, prisma.platformAuditLog, 'create', async () => ({}));
  stubMethod(t, prisma.user, 'count', async () => 0);
  stubMethod(t, prisma.project, 'count', async () => 0);

  const asRegularUser = await postJson('/api/v1/organizations', WIZARD_PAYLOAD, withToken('user-token'));
  assert.equal(asRegularUser.status, 403);

  const response = await postJson('/api/v1/organizations', WIZARD_PAYLOAD, withToken('admin-token'));
  assert.equal(response.status, 201);
  const org = (await json(response)).data;
  assert.equal(org.slug, 'eia-ems');
  assert.equal(org.status, 'active');
  assert.deepEqual(org.moduleKeys.sort(), ['project_core', 'tasks', 'tenant_admin'].sort());
});

test('PATCH /api/v1/organizations/:id/status suspends and reactivates', async (t) => {
  stubMethod(t, prisma.platformAuditLog, 'create', async () => ({}));
  stubMethod(t, prisma.organizationInvite, 'findFirst', async () => null);
  stubMethod(t, prisma.user, 'findFirst', async () => null);
  stubMethod(t, prisma.user, 'count', async () => 0);
  stubMethod(t, prisma.project, 'count', async () => 0);
  stubMethod(t, prisma.organization, 'findUnique', async () => ({ status: 'active' }));

  stubMethod(t, prisma.organization, 'update', async ({ data }: any) => ({ id: 1, name: 'Test Org', status: data.status, plan: null }));
  const suspended = await patchJson(`/api/v1/organizations/${encodeId(1)}/status`, { status: 'suspended', reason: 'payment_overdue', notifyAdmin: true }, withToken('admin-token'));
  assert.equal(suspended.status, 200);
  assert.equal((await json(suspended)).data.status, 'suspended');

  const missingReason = await patchJson(`/api/v1/organizations/${encodeId(1)}/status`, { status: 'suspended' }, withToken('admin-token'));
  assert.equal(missingReason.status, 400);

  const reactivated = await patchJson(`/api/v1/organizations/${encodeId(1)}/status`, { status: 'active' }, withToken('admin-token'));
  assert.equal(reactivated.status, 200);
  assert.equal((await json(reactivated)).data.status, 'active');
});

test('PATCH /api/v1/organizations/:id/subscription requires a reason and PUT tenant-admin supports both modes', async (t) => {
  const noReason = await patchJson(`/api/v1/organizations/${encodeId(1)}/subscription`, {
    planId: 'plan_growth', moduleKeys: ['tasks'], limits: { maxUsers: 100, maxProjects: 15, storageGb: 250 }, priceMonthly: 74999,
  }, withToken('admin-token'));
  assert.equal(noReason.status, 400);

  stubMethod(t, prisma.organization, 'findUnique', async () => ({
    planId: 'plan_starter', moduleKeys: ['tenant_admin', 'project_core'], priceMonthly: 24999,
    maxUsers: 25, maxProjects: 3, storageGb: 50, plan: { name: 'Starter' }, name: 'Test Org',
  }));
  stubMethod(t, prisma.plan, 'findUnique', async () => ({ name: 'Growth' }));
  stubMethod(t, prisma.organization, 'update', async ({ data }: any) => ({ id: 1, name: 'Test Org', ...data, plan: { name: 'Growth' } }));
  stubMethod(t, prisma.platformAuditLog, 'create', async () => ({}));
  stubMethod(t, prisma.organizationInvite, 'findFirst', async () => null);
  stubMethod(t, prisma.user, 'findFirst', async () => null);
  stubMethod(t, prisma.user, 'count', async () => 0);
  stubMethod(t, prisma.project, 'count', async () => 0);
  const upgraded = await patchJson(`/api/v1/organizations/${encodeId(1)}/subscription`, {
    planId: 'plan_growth', moduleKeys: ['tasks', 'planning'], limits: { maxUsers: 100, maxProjects: 15, storageGb: 250 }, priceMonthly: 74999,
    reason: 'Customer upgraded to Growth',
  }, withToken('admin-token'));
  assert.equal(upgraded.status, 200);
  assert.ok((await json(upgraded)).data.moduleKeys.includes('tenant_admin'));

  stubMethod(t, prisma.user, 'update', async ({ where, data }: any) => ({ id: where.id, name: 'Existing User', email: 'existing@example.com', ...data }));
  stubMethod(t, prisma.role, 'findUnique', async () => ({ id: 3, name: 'organization-admin' }));
  const existing = await putJson(`/api/v1/organizations/${encodeId(1)}/tenant-admin`, { mode: 'existing', userId: 7 }, withToken('admin-token'));
  assert.equal(existing.status, 200);

  stubMethod(t, prisma.organizationInvite, 'updateMany', async () => ({ count: 1 }));
  stubMethod(t, prisma.organizationInvite, 'create', async ({ data }: any) => ({ id: 2, ...data }));
  const invited = await putJson(`/api/v1/organizations/${encodeId(1)}/tenant-admin`, { mode: 'invite', name: 'New Admin', email: 'new-admin@example.com' }, withToken('admin-token'));
  assert.equal(invited.status, 200);
});

test('GET/POST /api/v1/plans and GET /api/v1/modules', async (t) => {
  stubMethod(t, prisma.plan, 'findMany', async () => [{ id: 'plan_starter', maxUsers: 25, maxProjects: 3, storageGb: 50, _count: { organizations: 2 } }]);
  const plans = await fetch(`${baseURL}/api/v1/plans`, withToken('admin-token'));
  assert.equal(plans.status, 200);
  assert.equal((await json(plans)).data[0].limits.maxUsers, 25);

  stubMethod(t, prisma.plan, 'create', async ({ data }: any) => ({ ...data, id: 'plan_custom', maxUsers: data.maxUsers, maxProjects: data.maxProjects, storageGb: data.storageGb }));
  stubMethod(t, prisma.platformAuditLog, 'create', async () => ({}));
  const created = await postJson('/api/v1/plans', {
    code: 'CUSTOM', name: 'Custom', priceMonthly: 50000, includedModuleKeys: ['tenant_admin', 'project_core'],
    limits: { maxUsers: 50, maxProjects: 10, storageGb: 100 },
  }, withToken('admin-token'));
  assert.equal(created.status, 201);

  stubMethod(t, prisma.platformModule, 'findMany', async () => [{ key: 'tenant_admin', name: 'Administration', isCore: true }]);
  const modules = await fetch(`${baseURL}/api/v1/modules`, withToken('admin-token'));
  assert.equal(modules.status, 200);
  assert.equal((await json(modules)).data[0].key, 'tenant_admin');
});

test('GET/PUT /api/v1/settings', async (t) => {
  stubMethod(t, prisma.platformSettings, 'upsert', async () => ({ id: 1, defaultTrialDays: 14, platformName: 'BlenAxis' }));
  stubMethod(t, prisma.platformAuditLog, 'create', async () => ({}));
  const settings = await fetch(`${baseURL}/api/v1/settings`, withToken('admin-token'));
  assert.equal(settings.status, 200);
  assert.equal((await json(settings)).data.defaultTrialDays, 14);

  const updated = await putJson('/api/v1/settings', { defaultTrialDays: 30 }, withToken('admin-token'));
  assert.equal(updated.status, 200);
});

test('GET /api/v1/dashboard/summary aggregates organizations, seats and needs-attention', async (t) => {
  const now = new Date();
  const soon = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000); // 3 days out -> trial_ending
  stubMethod(t, prisma.organization, 'findMany', async () => [
    {
      id: 1, name: 'Org A', status: 'active', maxUsers: 25, priceMonthly: 24999,
      subscriptionStatus: 'active', subscriptionRenewsAt: null, subscriptionStartsAt: null,
      createdAt: now, updatedAt: now, plan: { name: 'Starter' },
    },
    {
      id: 2, name: 'Org B', status: 'trial', maxUsers: 25, priceMonthly: 0,
      subscriptionStatus: 'trialing', subscriptionRenewsAt: soon, subscriptionStartsAt: now,
      createdAt: now, updatedAt: now, plan: { name: 'Starter' },
    },
  ]);
  stubMethod(t, prisma.user, 'groupBy', async () => [{ organizationId: 1, _count: 23 }]);
  stubMethod(t, prisma.project, 'groupBy', async () => [{ organizationId: 1, _count: 2 }]);
  stubMethod(t, prisma.organizationInvite, 'findFirst', async () => null);
  stubMethod(t, prisma.user, 'findFirst', async () => null);

  const response = await fetch(`${baseURL}/api/v1/dashboard/summary`, withToken('admin-token'));
  assert.equal(response.status, 200);
  const { data } = await json(response);
  assert.equal(data.totals.organizations, 2);
  assert.equal(data.totals.activeOrganizations, 1);
  assert.equal(data.totals.users, 23);
  assert.equal(data.totals.monthlyRecurringRevenue, 24999);
  assert.equal(data.seats.sold, 50);
  assert.equal(data.organizationsByStatus.trial, 1);
  assert.ok(data.needsAttention.some((item: any) => item.kind === 'trial_ending'));
  // Org A: 23/25 users = 92% >= 90% of its limit -> near_limit.
  assert.ok(data.needsAttention.some((item: any) => item.kind === 'near_limit'));
  assert.equal(data.recentOrganizations.length, 2);
});

test('Invite accept flow: GET the invite, then accept it to become the tenant Organization Admin', async (t) => {
  const inviteRow = {
    id: 1, organizationId: 1, name: 'Asdfg', email: 'invitee@example.com', token: 'a-valid-token',
    status: 'pending', expiresAt: new Date(Date.now() + 1000 * 60 * 60), organization: { name: 'Expertinasia Apps' },
  };
  stubMethod(t, prisma.organizationInvite, 'findUnique', async () => inviteRow);
  const fetched = await fetch(`${baseURL}/api/v1/organization-invites/a-valid-token`);
  assert.equal(fetched.status, 200);
  assert.equal((await json(fetched)).data.organizationName, 'Expertinasia Apps');

  stubMethod(t, prisma.user, 'findUnique', async () => null);
  stubMethod(t, prisma.role, 'findUnique', async () => ({ id: 3, name: 'organization-admin' }));
  let created: any;
  stubMethod(t, prisma.organizationInvite, 'update', async ({ data }: any) => {
    created = data.createdUser.create;
    return { status: data.status, createdUser: { id: 42, ...created, role: { name: 'organization-admin' } } };
  });

  const originalCreateUser = firebaseAuth.createUser;
  firebaseAuth.createUser = (async ({ email }: any) => ({ uid: 'fb-invited-uid', email })) as unknown as typeof firebaseAuth.createUser;
  t.after(() => { firebaseAuth.createUser = originalCreateUser; });

  const originalFetch = globalThis.fetch;
  globalThis.fetch = (async (input: any, init?: any) => {
    const url = typeof input === 'string' ? input : input.url;
    if (!url.startsWith('https://identitytoolkit.googleapis.com')) return originalFetch(input, init);
    return { ok: true, json: async () => ({ idToken: 'fake-id-token', refreshToken: 'fake-refresh-token' }) } as Response;
  }) as typeof fetch;
  t.after(() => { globalThis.fetch = originalFetch; });

  const accepted = await postJson('/api/v1/organization-invites/a-valid-token/accept', { password: 'Str0ngPassw0rd!' });
  assert.equal(accepted.status, 201);
  assert.equal((await json(accepted)).data.user.role, 'organization-admin');
  // The tenant role is what grants access in the tenant app, not only the legacy role_id.
  assert.deepEqual(created.tenantRoles, { create: { roleId: 3 } });

  const expiredInvite = { ...inviteRow, expiresAt: new Date(Date.now() - 1000) };
  stubMethod(t, prisma.organizationInvite, 'findUnique', async () => expiredInvite);
  const expired = await fetch(`${baseURL}/api/v1/organization-invites/a-valid-token`);
  assert.equal(expired.status, 410);
});

test('GET /api/v1/projects requires authentication but not admin, POST requires admin', async (t) => {
  const noAuth = await fetch(`${baseURL}/api/v1/projects`);
  assert.equal(noAuth.status, 401);

  stubMethod(t, prisma.project, 'findMany', async () => []);
  stubMethod(t, prisma.project, 'count', async () => 0);
  const asRegularUser = await fetch(`${baseURL}/api/v1/projects`, withToken('user-token'));
  assert.equal(asRegularUser.status, 200);

  const createAsRegularUser = await postJson('/api/v1/projects', { name: 'Riverside Metro Interchange' }, withToken('user-token'));
  assert.equal(createAsRegularUser.status, 403);

  stubMethod(t, prisma.project, 'create', async ({ data }: any) => ({ id: 1, status: 'draft', ...data }));
  stubMethod(t, prisma.projectActivityLog, 'create', async () => ({}));
  const asAdmin = await postJson('/api/v1/projects', { name: 'Riverside Metro Interchange', code: 'RMI' }, withToken('admin-token'));
  assert.equal(asAdmin.status, 201);
  assert.equal((await json(asAdmin)).data.code, 'RMI');
});

test('Project workflow: submit moves draft to submitted, approve moves submitted to active, and invalid transitions are rejected', async (t) => {
  stubMethod(t, prisma.projectActivityLog, 'create', async () => ({}));

  stubMethod(t, prisma.project, 'findUnique', async () => ({ status: 'draft' }));
  const approveTooEarly = await postJson(`/api/v1/projects/${encodeId(1)}/approve`, {}, withToken('admin-token'));
  assert.equal(approveTooEarly.status, 409);

  stubMethod(t, prisma.project, 'update', async ({ data }: any) => ({ id: 1, status: data.status }));
  const submitted = await postJson(`/api/v1/projects/${encodeId(1)}/submit`, {}, withToken('admin-token'));
  assert.equal(submitted.status, 200);
  assert.equal((await json(submitted)).data.status, 'submitted');

  stubMethod(t, prisma.project, 'findUnique', async () => ({ status: 'submitted' }));
  const approved = await postJson(`/api/v1/projects/${encodeId(1)}/approve`, {}, withToken('admin-token'));
  assert.equal(approved.status, 200);
  assert.equal((await json(approved)).data.status, 'active');
});

test('Project team: add and remove a team member', async (t) => {
  stubMethod(t, prisma.projectActivityLog, 'create', async () => ({}));
  stubMethod(t, prisma.projectTeamMember, 'create', async ({ data }: any) => ({
    id: 10, ...data, user: { id: data.userId, name: 'Marcus Chen', email: 'marcus@example.com' },
  }));
  const added = await postJson(
    `/api/v1/projects/${encodeId(1)}/team`,
    { userId: 2, roleOnProject: 'QA Engineer', department: 'QA/QC' },
    withToken('admin-token'),
  );
  assert.equal(added.status, 201);
  assert.equal((await json(added)).data.roleOnProject, 'QA Engineer');

  stubMethod(t, prisma.projectTeamMember, 'findFirst', async () => null);
  const removeMissing = await fetch(`${baseURL}/api/v1/projects/${encodeId(1)}/team/${encodeId(999)}`, withToken('admin-token', { method: 'DELETE' }));
  assert.equal(removeMissing.status, 404);
});

test('Project documents: uploading a disallowed file type is rejected, and an allowed one is stored and listed', async (t) => {
  const uploadedPaths: string[] = [];
  stubMethod(t, prisma.projectActivityLog, 'create', async () => ({}));
  stubMethod(t, prisma.projectDocument, 'create', async ({ data }: any) => {
    uploadedPaths.push(data.filePath);
    return { id: 1, ...data };
  });
  t.after(async () => {
    const fs = await import('node:fs/promises');
    const path = await import('node:path');
    const { UPLOAD_ROOT } = await import('../src/middlewares/upload.js');
    await Promise.all(uploadedPaths.map((filePath) => fs.unlink(path.join(UPLOAD_ROOT, 'projects', filePath)).catch(() => {})));
  });

  const badType = new FormData();
  badType.append('file', new Blob(['not a real image'], { type: 'image/png' }), 'photo.png');
  const rejected = await fetch(`${baseURL}/api/v1/projects/${encodeId(1)}/documents`, {
    ...withToken('admin-token'), method: 'POST', body: badType,
  });
  assert.equal(rejected.status, 400);

  const goodType = new FormData();
  goodType.append('file', new Blob(['%PDF-1.4 fake'], { type: 'application/pdf' }), 'Project_Brief_v2.pdf');
  const uploaded = await fetch(`${baseURL}/api/v1/projects/${encodeId(1)}/documents`, {
    ...withToken('admin-token'), method: 'POST', body: goodType,
  });
  assert.equal(uploaded.status, 201);
  assert.equal((await json(uploaded)).data.fileName, 'Project_Brief_v2.pdf');

  stubMethod(t, prisma.projectDocument, 'findMany', async () => [{ id: 1, fileName: 'Project_Brief_v2.pdf' }]);
  const listed = await fetch(`${baseURL}/api/v1/projects/${encodeId(1)}/documents`, withToken('user-token'));
  assert.equal(listed.status, 200);
  assert.equal((await json(listed)).data.length, 1);
});

test('WBS: create requires a valid projectId, rejects a duplicate code within a project, and supports the parent/child fields from the Figma form', async (t) => {
  stubMethod(t, prisma.wbs, 'create', async ({ data }: any) => {
    if (data.projectId === 999) throw Object.assign(new Error('Foreign key constraint failed'), { code: 'P2003' });
    return { id: 5, status: 'draft', ...data };
  });

  const badProject = await postJson('/api/v1/wbs', { projectId: 999, code: '1.0', name: 'Eastfield Project Area' }, withToken('admin-token'));
  assert.equal(badProject.status, 400);

  const created = await postJson('/api/v1/wbs', {
    projectId: 1, code: '1.1.1.1', name: 'Excavation & Shoring Plan', parentId: 4,
    discipline: 'Civil / Geotechnical', location: 'Station Box · West Block',
    responsibleTeam: 'Shoring Division · Eng. R. Fernandes', status: 'draft',
  }, withToken('admin-token'));
  assert.equal(created.status, 201);
  const payload = await json(created);
  assert.equal(payload.data.code, '1.1.1.1');
  assert.equal(payload.data.parentId, 4);
  assert.equal(payload.data.status, 'draft');

  // Duplicate code within the same project → 409, matching schema's @@unique([projectId, code]).
  stubMethod(t, prisma.wbs, 'create', async () => {
    throw Object.assign(new Error('Unique constraint failed'), { code: 'P2002' });
  });
  const duplicate = await postJson('/api/v1/wbs', { projectId: 1, code: '1.1.1.1', name: 'Duplicate' }, withToken('admin-token'));
  assert.equal(duplicate.status, 409);
});

test('WBS: rejects an invalid status and lists filtered by projectId/parentId', async (t) => {
  const badStatus = await postJson('/api/v1/wbs', { projectId: 1, code: '2.0', name: 'X', status: 'in-progress' }, withToken('admin-token'));
  assert.equal(badStatus.status, 400);

  stubMethod(t, prisma.wbs, 'findMany', async ({ where }: any) => {
    assert.equal(where.projectId, 1);
    assert.equal(where.parentId, null);
    return [{ id: 1, projectId: 1, parentId: null, code: '1.0', name: 'Eastfield Project Area', status: 'approved' }];
  });
  stubMethod(t, prisma.wbs, 'count', async () => 1);
  const response = await fetch(`${baseURL}/api/v1/wbs?projectId=1&parentId=root`, withToken('user-token'));
  assert.equal(response.status, 200);
  assert.equal((await json(response)).data.items[0].code, '1.0');
});

test('role :id accepts a base64url-encoded id and rejects a malformed one', async (t) => {
  stubMethod(t, prisma.role, 'findUnique', async ({ where }: any) => (where.id === 7 ? { id: 7, name: 'admin', description: null, isSystem: true, createdAt: new Date(), updatedAt: new Date(), permissions: [] } : null));
  const encoded = encodeId(7);
  const valid = await fetch(`${baseURL}/api/v1/roles/${encoded}`, withToken('admin-token'));
  assert.equal(valid.status, 200);
  assert.equal((await json(valid)).data.id, 7);

  const invalid = await fetch(`${baseURL}/api/v1/roles/not-valid-base64!!`, withToken('admin-token'));
  assert.equal(invalid.status, 400);
});

test('health reports database availability', async (t) => {
  const stub = stubMethod(t, prisma, '$queryRaw', async () => [{ result: 1 }]);
  let response = await fetch(`${baseURL}/api/v1/health`);
  assert.equal(response.status, 200);
  stub.mock.mockImplementation(async () => { throw new Error('offline'); });
  response = await fetch(`${baseURL}/api/v1/health`);
  assert.equal(response.status, 503);
  assert.equal((await json(response)).success, false);
});

test('/docs requires Basic Auth credentials', async () => {
  const response = await fetch(`${baseURL}/docs/`);
  assert.equal(response.status, 401);
  assert.match(response.headers.get('www-authenticate') ?? '', /Basic/);
});

test('/docs rejects wrong credentials', async () => {
  const auth = Buffer.from('wrong:creds').toString('base64');
  const response = await fetch(`${baseURL}/docs/`, { headers: { Authorization: `Basic ${auth}` } });
  assert.equal(response.status, 401);
});

test('/docs accepts correct credentials and remembers the session via cookie', async () => {
  const auth = Buffer.from('test-docs-user:test-docs-password').toString('base64');
  const first = await fetch(`${baseURL}/docs/`, { headers: { Authorization: `Basic ${auth}` } });
  assert.equal(first.status, 200);
  const setCookie = first.headers.get('set-cookie');
  assert.ok(setCookie?.includes('docs_session='));

  // Reusing the cookie (no Authorization header this time) should still work.
  const cookie = setCookie!.split(';')[0];
  const second = await fetch(`${baseURL}/docs/`, { headers: { Cookie: cookie } });
  assert.equal(second.status, 200);
});

test('unknown routes and malformed JSON retain the error contract', async () => {
  let response = await fetch(`${baseURL}/missing`);
  assert.equal(response.status, 404);
  assert.equal((await json(response)).success, false);
  response = await fetch(`${baseURL}/api/v1/auth/logout`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{',
  });
  assert.equal(response.status, 400);
  assert.equal((await json(response)).message, 'Invalid JSON body');
});
