import assert from 'node:assert/strict';
import type { Server } from 'node:http';
import { after, before, test } 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/createUser/fetch
// are all stubbed), just needs to satisfy env validation and cert()'s 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';
// High enough that this file's own requests never trip the auth limiter.
process.env.AUTH_RATE_LIMIT_MAX = '1000';
process.env.DOCS_USERNAME = 'test-docs-user';
process.env.DOCS_PASSWORD = 'test-docs-password';
// Fake S3 settings: S3Client.send is stubbed below, so nothing reaches AWS.
process.env.AWS_REGION = 'ap-south-1';
process.env.AWS_ACCESS_KEY_ID = 'test-access-key';
process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-key';
process.env.AWS_S3_BUCKET = 'blenaxis-test';

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');

let server: Server;
let baseURL: string;

// Neither Postgres nor Firebase is reachable in tests, so stub the Prisma
// calls auth.model.ts makes, firebaseAuth.verifyIdToken/createUser, and the
// Identity Toolkit REST call (global fetch) — same approach as api.test.ts.
const DEFAULT_ROLE = { id: 1, name: 'user', description: 'Default role', isSystem: true, createdAt: new Date(), updatedAt: new Date() };
const usersById = new Map<number, any>();
const usersByFirebaseUid = new Map<string, any>();
const usersByEmail = new Map<string, any>();
let nextId = 1;
let nextFirebaseUid = 1;

// Maps a fake bearer token to the decoded Firebase claims it should produce.
const tokenClaims = new Map<string, any>();
// Maps "email:password" to the Identity Toolkit sign-in outcome it should produce.
const signInOutcomes = new Map<string, { ok: boolean; body: any }>();
// Maps a refresh token to the securetoken.googleapis.com exchange outcome it should produce.
const refreshOutcomes = new Map<string, { ok: boolean; body: any }>();

function withRole(user: any) {
  return { ...user, role: user.role ?? DEFAULT_ROLE, tenantRoles: user.tenantRoles ?? [] };
}

const originalRoleUpsert = prisma.role.upsert;
const originalFindUnique = prisma.user.findUnique;
const originalCreate = prisma.user.create;
const originalUpdate = prisma.user.update;
const originalFindFirst = prisma.user.findFirst;
const originalVerifyIdToken = firebaseAuth.verifyIdToken;
const originalCreateUser = firebaseAuth.createUser;
const originalFetch = globalThis.fetch;

before(async () => {
  prisma.role.upsert = (async () => DEFAULT_ROLE) as unknown as typeof prisma.role.upsert;
  prisma.user.findUnique = (async ({ where }: any) => {
    const user = where.firebaseUid
      ? usersByFirebaseUid.get(where.firebaseUid)
      : where.email
        ? usersByEmail.get(where.email)
        : usersById.get(where.id);
    return user ? withRole(user) : null;
  }) as unknown as typeof prisma.user.findUnique;
  prisma.user.create = (async ({ data }: any) => {
    const user = {
      id: nextId++, avatarUrl: null, emailVerified: false, isActive: true,
      roleId: DEFAULT_ROLE.id, createdAt: new Date(), updatedAt: new Date(), ...data,
    };
    usersById.set(user.id, user);
    usersByFirebaseUid.set(user.firebaseUid, user);
    usersByEmail.set(user.email, user);
    return withRole(user);
  }) as unknown as typeof prisma.user.create;
  prisma.user.update = (async ({ where, data }: any) => {
    const user = usersById.get(where.id);
    Object.assign(user, data);
    return withRole(user);
  }) as unknown as typeof prisma.user.update;
  // tenantAdmin() in session.model.ts: no Organization Admin in these fixtures.
  prisma.user.findFirst = (async () => null) as unknown as typeof prisma.user.findFirst;
  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;
  firebaseAuth.createUser = (async ({ email }: any) => {
    if (usersByEmail.has(email)) throw Object.assign(new Error('exists'), { code: 'auth/email-already-exists' });
    return { uid: `fb-uid-${nextFirebaseUid++}`, email };
  }) as unknown as typeof firebaseAuth.createUser;
  globalThis.fetch = (async (input: any, init?: any) => {
    const url = typeof input === 'string' ? input : input.url;
    if (url.startsWith('https://securetoken.googleapis.com')) {
      const params = new URLSearchParams(init?.body ?? '');
      const outcome = refreshOutcomes.get(params.get('refresh_token') ?? '') ?? {
        ok: false, body: { error: { message: 'INVALID_REFRESH_TOKEN' } },
      };
      return { ok: outcome.ok, json: async () => outcome.body } as Response;
    }
    if (!url.startsWith('https://identitytoolkit.googleapis.com')) {
      return originalFetch(input, init);
    }
    const body = JSON.parse(init?.body ?? '{}');
    const outcome = signInOutcomes.get(`${body.email}:${body.password}`) ?? {
      ok: false, body: { error: { message: 'EMAIL_NOT_FOUND' } },
    };
    return { ok: outcome.ok, json: async () => outcome.body } as Response;
  }) as typeof fetch;

  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.role.upsert = originalRoleUpsert;
  prisma.user.findUnique = originalFindUnique;
  prisma.user.create = originalCreate;
  prisma.user.update = originalUpdate;
  prisma.user.findFirst = originalFindFirst;
  firebaseAuth.verifyIdToken = originalVerifyIdToken;
  firebaseAuth.createUser = originalCreateUser;
  globalThis.fetch = originalFetch;
  await new Promise<void>((resolve) => server.close(() => resolve()));
});

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

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

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

function registerSignIn(email: string, password: string, firebaseUid: string) {
  signInOutcomes.set(`${email}:${password}`, {
    ok: true,
    body: { localId: firebaseUid, idToken: `id-${firebaseUid}`, refreshToken: `refresh-${firebaseUid}` },
  });
}

test('/me rejects requests without a token', async () => {
  const response = await fetch(`${baseURL}/api/v1/auth/me`);
  assert.equal(response.status, 401);
});

test('/me rejects an invalid or expired token', async () => {
  const response = await fetch(`${baseURL}/api/v1/auth/me`, withToken('not-a-real-token'));
  assert.equal(response.status, 401);
});

test('/me provisions a local user on first sign-in and returns it on later requests', async () => {
  const idToken = 'token-shubh';
  tokenClaims.set(idToken, { uid: 'firebase-uid-1', email: 'shubh@example.com', name: 'Shubh', picture: 'https://example.com/a.png', email_verified: true });

  const first = await fetch(`${baseURL}/api/v1/auth/me`, withToken(idToken));
  assert.equal(first.status, 200);
  const firstPayload = await json(first);
  assert.equal(firstPayload.data.user.email, 'shubh@example.com');
  assert.equal(firstPayload.data.user.role, 'user');
  assert.equal(firstPayload.data.tenant, null);

  const second = await fetch(`${baseURL}/api/v1/auth/me`, withToken(idToken));
  assert.equal(second.status, 200);
  const secondPayload = await json(second);
  assert.equal(secondPayload.data.user.id, firstPayload.data.user.id);
});

test('logout succeeds for an authenticated user', async () => {
  const idToken = 'token-logout';
  tokenClaims.set(idToken, { uid: 'firebase-uid-2', email: 'logout@example.com', name: 'Logout User', email_verified: false });

  const response = await fetch(`${baseURL}/api/v1/auth/logout`, withToken(idToken, { method: 'POST' }));
  assert.equal(response.status, 200);
  assert.equal((await json(response)).success, true);
});

test('signup rejects a weak password', async () => {
  const response = await post('/api/v1/auth/signup', { email: 'weak@example.com', password: 'weak' });
  assert.equal(response.status, 400);
  assert.equal((await json(response)).success, false);
});

test('signup creates the Firebase + local user, signs in, and rejects a duplicate email', async () => {
  registerSignIn('newuser@example.com', 'Str0ngPass!', 'fb-uid-signup-1');
  const response = await post('/api/v1/auth/signup', { email: 'newuser@example.com', password: 'Str0ngPass!' });
  assert.equal(response.status, 201);
  const payload = await json(response);
  assert.equal(payload.data.user.email, 'newuser@example.com');
  assert.equal(payload.data.idToken, undefined);
  assert.equal(payload.data.accessToken, 'id-fb-uid-signup-1');
  assert.equal(payload.data.refreshToken, 'refresh-fb-uid-signup-1');
  assert.ok(response.headers.get('x-id-token'));
  assert.ok(response.headers.get('x-refresh-token'));

  const duplicate = await post('/api/v1/auth/signup', { email: 'newuser@example.com', password: 'Str0ngPass!' });
  assert.equal(duplicate.status, 409);
});

test('login rejects an account that was never signed up', async () => {
  const response = await post('/api/v1/auth/login', { email: 'ghost@example.com', password: 'Str0ngPass!' });
  assert.equal(response.status, 404);
});

test('login rejects a wrong password and succeeds with the correct one', async () => {
  registerSignIn('login@example.com', 'Str0ngPass!', 'fb-uid-login-1');
  await post('/api/v1/auth/signup', { email: 'login@example.com', password: 'Str0ngPass!' });

  const wrongPassword = await post('/api/v1/auth/login', { email: 'login@example.com', password: 'WrongPass1' });
  assert.equal(wrongPassword.status, 401);

  const response = await post('/api/v1/auth/login', { email: 'login@example.com', password: 'Str0ngPass!' });
  assert.equal(response.status, 200);
  const payload = await json(response);
  assert.equal(payload.data.user.email, 'login@example.com');
  assert.equal(payload.data.idToken, undefined);
  assert.equal(payload.data.accessToken, 'id-fb-uid-login-1');
  assert.equal(payload.data.refreshToken, 'refresh-fb-uid-login-1');
  assert.ok(response.headers.get('x-id-token'));
  assert.ok(response.headers.get('x-refresh-token'));
});

test('POST /api/v1/auth/refresh exchanges a refresh token for a new session, and rejects an invalid one', async () => {
  refreshOutcomes.set('a-valid-refresh-token', {
    ok: true, body: { id_token: 'new-id-token', refresh_token: 'rotated-refresh-token' },
  });

  const invalid = await post('/api/v1/auth/refresh', { refreshToken: 'not-a-real-token' });
  assert.equal(invalid.status, 401);

  const response = await post('/api/v1/auth/refresh', { refreshToken: 'a-valid-refresh-token' });
  assert.equal(response.status, 200);
  const payload = await json(response);
  assert.equal(payload.data.accessToken, 'new-id-token');
  assert.equal(payload.data.refreshToken, 'rotated-refresh-token');
  assert.equal(response.headers.get('x-id-token'), 'new-id-token');
});

test('signup still succeeds (without tokens) if the automatic sign-in fails', async () => {
  // No registerSignIn call here, so the post-create sign-in falls through to
  // the fetch stub's default failure outcome — simulating e.g. a misconfigured
  // FIREBASE_WEB_API_KEY. The account must still exist; the client just gets
  // told to log in separately instead of a 500.
  const response = await post('/api/v1/auth/signup', { email: 'nosignin@example.com', password: 'Str0ngPass!' });
  assert.equal(response.status, 201);
  const payload = await json(response);
  assert.equal(payload.data.user.email, 'nosignin@example.com');
  assert.equal(response.headers.get('x-id-token'), null);
});

// A tenant user: Eastfield subscribes to Planning but not Procurement, and the
// role has both, so only the subscribed module's permissions come back.
function tenantFixture(email: string, firebaseUid: string, { isActive = true, status = 'active' } = {}) {
  const organization = { id: 7, name: 'Eastfield Developers', code: 'eastfield', isActive, status, moduleKeys: ['tenant_admin', 'project_core', 'planning'] };
  const permission = (name: string, module: string) => ({ permission: { name, module } });
  const role = {
    id: 3, name: 'planning-manager', label: 'Planning Manager', scope: 'assigned', isSystem: false,
    modules: ['project_core', 'planning', 'procurement'],
    permissions: [
      permission('planning.wbs.view', 'planning'),
      permission('planning.wbs.manage', 'planning'),
      permission('procurement.orders.view', 'procurement'),
    ],
  };
  const user = {
    id: nextId++, name: 'Sneha', email, firebaseUid, avatarUrl: null, emailVerified: true, isActive: true,
    roleId: DEFAULT_ROLE.id, role: DEFAULT_ROLE, tenantRoles: [{ role }],
    organizationId: organization.id, organization, createdAt: new Date(), updatedAt: new Date(),
  };
  usersById.set(user.id, user);
  usersByFirebaseUid.set(firebaseUid, user);
  usersByEmail.set(email, user);
  registerSignIn(email, 'Str0ngPass!', firebaseUid);
  return user;
}

test('login returns the tenant and effective permissions for subscribed modules only', async () => {
  tenantFixture('sneha@example.com', 'fb-uid-sneha');
  const response = await post('/api/v1/auth/login', { email: 'sneha@example.com', password: 'Str0ngPass!' });
  assert.equal(response.status, 200);
  const { data } = await json(response);
  assert.deepEqual(data.user.roles, ['Planning Manager']);
  assert.deepEqual(data.user.permissions.sort(), ['planning.wbs.manage', 'planning.wbs.view']);
  assert.deepEqual(data.user.modules, ['tenant_admin', 'project_core', 'planning']);
  assert.equal(data.user.scope, 'assigned');
  assert.equal(data.tenant.name, 'Eastfield Developers');
  assert.equal(data.tenant.slug, 'eastfield');
  assert.deepEqual(data.tenant.moduleKeys, ['tenant_admin', 'project_core', 'planning']);
  assert.equal(typeof data.tenant.id, 'string');
  assert.ok(response.headers.get('x-id-token'));
});

test('a suspended organization gets 403 tenant_suspended at login and on later requests', async () => {
  tenantFixture('meera@example.com', 'fb-uid-meera', { isActive: false });
  const wrong = await post('/api/v1/auth/login', { email: 'meera@example.com', password: 'WrongPass1' });
  assert.equal(wrong.status, 401, 'suspension is only revealed after a correct password');

  const login = await post('/api/v1/auth/login', { email: 'meera@example.com', password: 'Str0ngPass!' });
  assert.equal(login.status, 403);
  const body = await json(login);
  assert.equal(body.code, 'tenant_suspended');
  assert.equal(body.organizationName, 'Eastfield Developers');
  assert.ok(body.supportEmail);

  tokenClaims.set('token-meera', { uid: 'fb-uid-meera' });
  const me = await fetch(`${baseURL}/api/v1/auth/me`, withToken('token-meera'));
  assert.equal(me.status, 403);
  assert.equal((await json(me)).code, 'tenant_suspended');
});

test('suspending from Super Admin (status only) blocks signed-in users on their next request', async () => {
  const user = tenantFixture('asha@example.com', 'fb-uid-asha');
  tokenClaims.set('token-asha', { uid: 'fb-uid-asha' });
  assert.equal((await fetch(`${baseURL}/api/v1/auth/me`, withToken('token-asha'))).status, 200);

  // PATCH /organizations/:id/status sets status; isActive may still be true on older rows.
  user.organization.status = 'suspended';
  const me = await fetch(`${baseURL}/api/v1/auth/me`, withToken('token-asha'));
  assert.equal(me.status, 403);
  assert.equal((await json(me)).code, 'tenant_suspended');
  const login = await post('/api/v1/auth/login', { email: 'asha@example.com', password: 'Str0ngPass!' });
  assert.equal(login.status, 403);
  refreshOutcomes.set('refresh-asha', { ok: true, body: { id_token: 'id-asha', refresh_token: 'refresh-asha-2', user_id: 'fb-uid-asha' } });
  const refreshed = await post('/api/v1/auth/refresh', { refreshToken: 'refresh-asha' });
  assert.equal(refreshed.status, 403, 'a refresh token cannot outlive the suspension');
  assert.equal((await json(refreshed)).code, 'tenant_suspended');
});

test('PATCH /me edits your own name, phone and job title', async () => {
  const user = tenantFixture('ravi@example.com', 'fb-uid-ravi');
  tokenClaims.set('token-ravi', { uid: 'fb-uid-ravi' });
  const originalUpdateUser = firebaseAuth.updateUser;
  const displayNames: string[] = [];
  firebaseAuth.updateUser = (async (_uid: string, props: any) => { displayNames.push(props.displayName); return {}; }) as any;
  try {
    const patch = (body: unknown) => fetch(`${baseURL}/api/v1/auth/me`, withToken('token-ravi', {
      method: 'PATCH', headers: { Authorization: 'Bearer token-ravi', 'Content-Type': 'application/json' }, body: JSON.stringify(body),
    }));
    assert.equal((await patch({ name: 'R' })).status, 400);
    assert.equal((await patch({ name: 'Ravi Kumar', phone: 'call me' })).status, 400);

    assert.equal((await patch({ name: 'Ravi Kumar', email: 'hijack@example.com' })).status, 400, 'the sign-in email is not editable');
    const saved = await patch({ name: 'Ravi Kumar', phone: '+91 98765 43210', jobTitle: 'Site lead' });
    assert.equal(saved.status, 200);
    const { data } = await json(saved);
    assert.equal(data.user.name, 'Ravi Kumar');
    assert.equal(data.user.phone, '+91 98765 43210');
    assert.equal(data.user.jobTitle, 'Site lead');
    assert.equal(data.tenant.name, 'Eastfield Developers');
    assert.equal(user.email, 'ravi@example.com');
    assert.deepEqual(displayNames, ['Ravi Kumar']);
  } finally {
    firebaseAuth.updateUser = originalUpdateUser;
  }
});

test('POST /change-password checks the current password, sets the new one and returns fresh tokens', async () => {
  tenantFixture('mira@example.com', 'fb-uid-mira');
  tokenClaims.set('token-mira', { uid: 'fb-uid-mira' });
  const originalUpdateUser = firebaseAuth.updateUser;
  const changes: any[] = [];
  firebaseAuth.updateUser = (async (uid: string, props: any) => {
    changes.push([uid, props.password]);
    registerSignIn('mira@example.com', props.password, 'fb-uid-mira-new');
    return {};
  }) as any;
  try {
    const change = (body: unknown) => fetch(`${baseURL}/api/v1/auth/change-password`, withToken('token-mira', {
      method: 'POST', headers: { Authorization: 'Bearer token-mira', 'Content-Type': 'application/json' }, body: JSON.stringify(body),
    }));
    assert.equal((await change({ currentPassword: 'Str0ngPass!', newPassword: 'short1' })).status, 400);
    assert.equal((await change({ currentPassword: 'Str0ngPass!', newPassword: 'Str0ngPass!' })).status, 400, 'must differ');

    const wrong = await change({ currentPassword: 'WrongPass1', newPassword: 'N3wPassword' });
    assert.equal(wrong.status, 400, 'not 401, which would end the session');
    assert.equal((await json(wrong)).errors[0].field, 'currentPassword');
    assert.deepEqual(changes, []);

    const ok = await change({ currentPassword: 'Str0ngPass!', newPassword: 'N3wPassword' });
    assert.equal(ok.status, 200);
    assert.deepEqual(changes, [['fb-uid-mira', 'N3wPassword']]);
    const { data } = await json(ok);
    assert.equal(data.accessToken, 'id-fb-uid-mira-new');
    assert.equal(ok.headers.get('x-id-token'), 'id-fb-uid-mira-new');
  } finally {
    firebaseAuth.updateUser = originalUpdateUser;
  }
});

// ── Images in S3 (profile photo, generic upload) ──

async function withS3(t: import('node:test').TestContext) {
  const { S3Client } = await import('@aws-sdk/client-s3');
  const sent: { name: string; input: any }[] = [];
  const original = S3Client.prototype.send;
  S3Client.prototype.send = (async function (command: any) {
    sent.push({ name: command.constructor.name, input: command.input });
    return {};
  }) as any;
  t.after(() => { S3Client.prototype.send = original; });
  return sent;
}

function imageForm(name: string, type: string, bytes = 10) {
  const form = new FormData();
  form.append('file', new Blob([new Uint8Array(bytes)], { type }), name);
  return form;
}

test('profile photo: uploads to S3, returns a signed URL, replaces and removes the old file', async (t) => {
  const sent = await withS3(t);
  const user = tenantFixture('photo@example.com', 'fb-uid-photo');
  tokenClaims.set('token-photo', { uid: 'fb-uid-photo' });
  const send = (method: string, body?: FormData) =>
    fetch(`${baseURL}/api/v1/auth/me/avatar`, withToken('token-photo', { method, body }));

  const pdf = await send('POST', imageForm('cv.pdf', 'application/pdf'));
  assert.equal(pdf.status, 400, 'only images');
  const big = await send('POST', imageForm('huge.png', 'image/png', 5 * 1024 * 1024 + 1));
  assert.equal(big.status, 413);
  assert.match((await json(big)).message, /max 5 MB/);
  assert.equal(sent.length, 0);

  const first = await send('POST', imageForm('me.png', 'image/png'));
  assert.equal(first.status, 200);
  const firstKey = String(user.avatarUrl);
  assert.match(firstKey, /^orgs\/7\/avatars\/[0-9a-f-]{36}\.png$/);
  assert.deepEqual([sent[0].name, sent[0].input.Bucket, sent[0].input.Key, sent[0].input.ContentType], ['PutObjectCommand', 'blenaxis-test', firstKey, 'image/png']);
  const { data } = await json(first);
  assert.match(data.user.avatarUrl, /^https:\/\/blenaxis-test\.s3\.ap-south-1\.amazonaws\.com\/orgs\/7\/avatars\/.+X-Amz-Signature=/);

  assert.equal((await send('POST', imageForm('me2.jpg', 'image/jpeg'))).status, 200);
  assert.deepEqual(sent.map((c) => c.name), ['PutObjectCommand', 'PutObjectCommand', 'DeleteObjectCommand']);
  assert.equal(sent[2].input.Key, firstKey, 'the replaced photo is removed');

  const removed = await send('DELETE');
  assert.equal(removed.status, 200);
  assert.equal((await json(removed)).data.user.avatarUrl, null);
  assert.equal(user.avatarUrl, null);
  assert.equal(sent.at(-1)!.name, 'DeleteObjectCommand');
});

test('generic image upload stores under the organization; logo upload needs admin.organization.manage', async (t) => {
  const sent = await withS3(t);
  tenantFixture('images@example.com', 'fb-uid-images');
  tokenClaims.set('token-images', { uid: 'fb-uid-images' });

  const uploaded = await fetch(`${baseURL}/api/v1/uploads/images`, withToken('token-images', { method: 'POST', body: imageForm('site.webp', 'image/webp') }));
  assert.equal(uploaded.status, 201);
  const { data } = await json(uploaded);
  assert.match(data.key, /^orgs\/7\/images\/[0-9a-f-]{36}\.webp$/);
  assert.match(data.url, /X-Amz-Signature=/);
  assert.equal(data.contentType, 'image/webp');
  assert.equal(sent[0].input.Key, data.key);

  // A Planning Manager can't change the organization's logo.
  const logo = await fetch(`${baseURL}/api/v1/admin/organization/logo`, withToken('token-images', { method: 'POST', body: imageForm('logo.png', 'image/png') }));
  assert.equal(logo.status, 403);
  assert.equal(sent.length, 1);
});
