'use strict';

const { login, generatePKCE, request, app } = require('./setup');

const REDIRECT_URI = 'https://test.example.com/callback';

let token;
let clientId;
let clientSecret;

// Full OIDC Authorization Code + PKCE flow state
let authCode;
let accessToken;
let refreshToken;

beforeAll(async () => {
	token = await login();

	// Create a dedicated test OAuth client for the flow
	const res = await request(app)
		.post('/api/oauth/client/')
		.set('auth-token', token)
		.send({
			name: 'OAuth Flow Test',
			redirect_uris: REDIRECT_URI,
			scopes: 'openid profile email groups',
			token_lifetime: { access_token: 3600, refresh_token: 86400 },
		});

	if (res.status !== 200) {
		throw new Error('Could not create test OAuth client. Is test user in app_sso_oauth_admin? ' + JSON.stringify(res.body));
	}

	clientId = res.body.results.client_id;
	clientSecret = res.body.client_secret;
});

afterAll(async () => {
	if (clientId) {
		await request(app)
			.delete(`/api/oauth/client/${clientId}`)
			.set('auth-token', token);
	}
});

describe('OAuth client management API — /api/oauth/client', () => {
	// Regression: the ORM Model.toJSON() strips non-schema fields, so the
	// mapped client_id/scopes/etc. used to vanish from GET responses —
	// client_id came back undefined and the theta-env bootstrap's rotate
	// crashed with a 500. GET must expose client_id (and never the secret hash).
	test('GET / list exposes client_id and hides client_secret_hash', async () => {
		const res = await request(app)
			.get('/api/oauth/client/')
			.set('auth-token', token);
		expect(res.status).toBe(200);
		const mine = res.body.results.find((c) => c.client_id === clientId);
		expect(mine).toBeDefined();
		expect(mine.client_id).toBe(clientId);
		expect(mine).toHaveProperty('scopes');
		expect(mine).not.toHaveProperty('client_secret_hash');
	});

	test('GET /:id exposes client_id', async () => {
		const res = await request(app)
			.get(`/api/oauth/client/${clientId}`)
			.set('auth-token', token);
		expect(res.status).toBe(200);
		expect(res.body.results.client_id).toBe(clientId);
		expect(res.body.results).not.toHaveProperty('client_secret_hash');
	});

	test('PUT persists — a changed name survives a fresh GET', async () => {
		const created = await request(app)
			.post('/api/oauth/client/')
			.set('auth-token', token)
			.send({ name: 'put-persist-test', redirect_uris: REDIRECT_URI });
		expect(created.status).toBe(200);
		const id = created.body.results.client_id;

		const updated = await request(app)
			.put(`/api/oauth/client/${id}`)
			.set('auth-token', token)
			.send({ name: 'put-persist-test-renamed' });
		expect(updated.status).toBe(200);
		expect(updated.body.results.name).toBe('put-persist-test-renamed');

		const fetched = await request(app).get(`/api/oauth/client/${id}`).set('auth-token', token);
		expect(fetched.status).toBe(200);
		expect(fetched.body.results.name).toBe('put-persist-test-renamed');

		await request(app).delete(`/api/oauth/client/${id}`).set('auth-token', token);
	});

	// Regression: this route called client.remove(), but OAuthClient wraps
	// @simpleworkjs/orm's Resource model, whose instance method is .delete()
	// — .remove() doesn't exist on it (unlike the model-redis Tables
	// elsewhere in this app, e.g. api_token.js, which really do have
	// .remove()). The route's try/catch turned the resulting TypeError into
	// a plain 500 JSON response rather than a thrown exception, so every
	// prior DELETE call in this file's cleanup hooks silently "succeeded"
	// from Jest's point of view while leaving the client un-deleted.
	test('DELETE persists — the client is actually gone, not just a 200', async () => {
		const created = await request(app)
			.post('/api/oauth/client/')
			.set('auth-token', token)
			.send({ name: 'delete-persist-test', redirect_uris: REDIRECT_URI });
		expect(created.status).toBe(200);
		const id = created.body.results.client_id;

		const deleted = await request(app).delete(`/api/oauth/client/${id}`).set('auth-token', token);
		expect(deleted.status).toBe(200);

		const fetched = await request(app).get(`/api/oauth/client/${id}`).set('auth-token', token);
		expect(fetched.status).toBe(404);
	});

	test('list then rotate a client by its returned client_id (the bootstrap path)', async () => {
		// Reproduces exactly what the theta-env bootstrap does: create, list,
		// find by name, rotate by the client_id from the list response. Uses a
		// throwaway client so the shared flow client's secret is untouched.
		const created = await request(app)
			.post('/api/oauth/client/')
			.set('auth-token', token)
			.send({ name: 'rotate-regression', redirect_uris: REDIRECT_URI });
		expect(created.status).toBe(200);

		const list = await request(app).get('/api/oauth/client/').set('auth-token', token);
		const found = list.body.results.find((c) => c.name === 'rotate-regression');
		expect(found).toBeDefined();
		expect(found.client_id).toBeTruthy(); // was undefined before the fix

		const rotated = await request(app)
			.post(`/api/oauth/client/${found.client_id}/rotate`)
			.set('auth-token', token);
		expect(rotated.status).toBe(200);
		expect(rotated.body.client_secret).toBeTruthy();

		const deleted = await request(app).delete(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
		expect(deleted.status).toBe(200);

		const afterDelete = await request(app).get(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
		expect(afterDelete.status).toBe(404);
	});

	test('GET /:id unknown id returns 404, not 500', async () => {
		const res = await request(app)
			.get('/api/oauth/client/00000000-0000-0000-0000-000000000000')
			.set('auth-token', token);
		expect(res.status).toBeGreaterThanOrEqual(400);
		expect(res.status).toBeLessThan(500);
	});
});

describe('OIDC Discovery', () => {
	test('GET /.well-known/openid-configuration returns required fields', async () => {
		const res = await request(app).get('/.well-known/openid-configuration');

		expect(res.status).toBe(200);
		expect(res.body).toHaveProperty('issuer');
		expect(res.body).toHaveProperty('authorization_endpoint');
		expect(res.body).toHaveProperty('token_endpoint');
		expect(res.body).toHaveProperty('userinfo_endpoint');
		expect(res.body.response_types_supported).toContain('code');
		expect(res.body.grant_types_supported).toContain('authorization_code');
		expect(res.body.grant_types_supported).toContain('refresh_token');
		expect(res.body.code_challenge_methods_supported).toContain('S256');
	});

	test('advertises end_session_endpoint', async () => {
		const res = await request(app).get('/.well-known/openid-configuration');
		expect(res.body).toHaveProperty('end_session_endpoint');
	});
});

describe('OAuth — GET /oauth/logout (RP-initiated logout)', () => {
	test('renders logout page with no redirect', async () => {
		const res = await request(app).get('/oauth/logout');
		expect(res.status).toBe(200);
	});

	test('accepts a post_logout_redirect_uri on a registered client origin', async () => {
		const res = await request(app)
			.get('/oauth/logout')
			.query({ post_logout_redirect_uri: 'https://test.example.com/' });
		expect(res.status).toBe(200);
		expect(res.text).toContain('https://test.example.com/');
	});

	test('rejects a post_logout_redirect_uri on an unregistered origin', async () => {
		const res = await request(app)
			.get('/oauth/logout')
			.query({ post_logout_redirect_uri: 'https://evil.example.com/' });
		expect(res.status).toBeGreaterThanOrEqual(400);
	});
});

describe('OAuth — GET /oauth/authorize (consent page validation)', () => {
	test('rejects unknown client_id', async () => {
		const { challenge } = generatePKCE();
		const res = await request(app).get('/oauth/authorize').query({
			response_type: 'code',
			client_id: '00000000-0000-0000-0000-000000000000',
			redirect_uri: REDIRECT_URI,
			scope: 'openid',
			code_challenge: challenge,
			code_challenge_method: 'S256',
		});
		expect(res.status).toBeGreaterThanOrEqual(400);
	});

	test('rejects unregistered redirect_uri', async () => {
		const { challenge } = generatePKCE();
		const res = await request(app).get('/oauth/authorize').query({
			response_type: 'code',
			client_id: clientId,
			redirect_uri: 'https://evil.example.com/callback',
			scope: 'openid',
			code_challenge: challenge,
			code_challenge_method: 'S256',
		});
		expect(res.status).toBeGreaterThanOrEqual(400);
	});

	test('rejects missing code_challenge (PKCE required)', async () => {
		const res = await request(app).get('/oauth/authorize').query({
			response_type: 'code',
			client_id: clientId,
			redirect_uri: REDIRECT_URI,
			scope: 'openid',
		});
		expect(res.status).toBeGreaterThanOrEqual(400);
	});

	test('valid params render the consent page', async () => {
		const { challenge } = generatePKCE();
		const res = await request(app).get('/oauth/authorize').query({
			response_type: 'code',
			client_id: clientId,
			redirect_uri: REDIRECT_URI,
			scope: 'openid profile email',
			state: 'teststate',
			code_challenge: challenge,
			code_challenge_method: 'S256',
		});
		// Returns HTML (the EJS consent page), not an error
		expect(res.status).toBe(200);
		expect(res.headers['content-type']).toMatch(/html/);
	});
});

describe('OAuth — POST /api/oauth/authorize (code issuance)', () => {
	test('issues an authorization code for an authenticated user', async () => {
		const { challenge, verifier } = generatePKCE();

		const res = await request(app)
			.post('/api/oauth/authorize')
			.set('auth-token', token)
			.send({
				response_type: 'code',
				client_id: clientId,
				redirect_uri: REDIRECT_URI,
				scope: 'openid profile email',
				state: 'teststate',
				code_challenge: challenge,
				code_challenge_method: 'S256',
			});

		expect(res.status).toBe(200);
		expect(res.body).toHaveProperty('redirect_url');

		const redirectUrl = new URL(res.body.redirect_url);
		expect(redirectUrl.searchParams.get('code')).toBeTruthy();
		expect(redirectUrl.searchParams.get('state')).toBe('teststate');

		// Save for token exchange tests
		authCode = redirectUrl.searchParams.get('code');
		// Also save the verifier so token exchange works
		res._pkceVerifier = verifier;

		// Store verifier on the module scope for next describe block
		global.__testPkceVerifier = verifier;
	});

	test('requires auth — 401 without token', async () => {
		const { challenge } = generatePKCE();
		const res = await request(app)
			.post('/api/oauth/authorize')
			.send({
				response_type: 'code',
				client_id: clientId,
				redirect_uri: REDIRECT_URI,
				scope: 'openid',
				code_challenge: challenge,
			});
		expect(res.status).toBe(401);
	});
});

describe('OAuth — POST /oauth/token (authorization_code grant)', () => {
	test('exchanges auth code + PKCE verifier for tokens', async () => {
		expect(authCode).toBeDefined();

		const res = await request(app)
			.post('/oauth/token')
			.type('form')
			.send({
				grant_type: 'authorization_code',
				code: authCode,
				redirect_uri: REDIRECT_URI,
				client_id: clientId,
				client_secret: clientSecret,
				code_verifier: global.__testPkceVerifier,
			});

		expect(res.status).toBe(200);
		expect(res.body).toHaveProperty('access_token');
		expect(res.body).toHaveProperty('refresh_token');
		expect(res.body).toHaveProperty('id_token');
		expect(res.body.token_type).toBe('Bearer');
		expect(res.body.expires_in).toBe(3600);

		accessToken = res.body.access_token;
		refreshToken = res.body.refresh_token;
	});

	test('rejects reuse of the same auth code', async () => {
		const res = await request(app)
			.post('/oauth/token')
			.type('form')
			.send({
				grant_type: 'authorization_code',
				code: authCode,
				redirect_uri: REDIRECT_URI,
				client_id: clientId,
				client_secret: clientSecret,
				code_verifier: global.__testPkceVerifier,
			});
		expect(res.status).toBe(400);
		expect(res.body.error).toBe('invalid_grant');
	});

	test('rejects wrong code_verifier (PKCE mismatch)', async () => {
		// Get a fresh code first
		const { challenge, verifier } = generatePKCE();
		const codeRes = await request(app)
			.post('/api/oauth/authorize')
			.set('auth-token', token)
			.send({
				response_type: 'code',
				client_id: clientId,
				redirect_uri: REDIRECT_URI,
				scope: 'openid',
				code_challenge: challenge,
				code_challenge_method: 'S256',
			});
		const freshCode = new URL(codeRes.body.redirect_url).searchParams.get('code');

		const res = await request(app)
			.post('/oauth/token')
			.type('form')
			.send({
				grant_type: 'authorization_code',
				code: freshCode,
				redirect_uri: REDIRECT_URI,
				client_id: clientId,
				client_secret: clientSecret,
				code_verifier: 'wrong-verifier-that-does-not-match',
			});
		expect(res.status).toBe(400);
		expect(res.body.error).toBe('invalid_grant');
	});

	test('rejects wrong client_secret', async () => {
		const { challenge } = generatePKCE();
		const res = await request(app)
			.post('/oauth/token')
			.type('form')
			.send({
				grant_type: 'authorization_code',
				code: 'doesnotmatter',
				redirect_uri: REDIRECT_URI,
				client_id: clientId,
				client_secret: 'wrong-secret',
				code_verifier: 'doesnotmatter',
			});
		expect(res.status).toBe(401);
		expect(res.body.error).toBe('invalid_client');
	});
});

describe('OAuth — GET /oauth/userinfo', () => {
	test('returns user claims for valid access token', async () => {
		expect(accessToken).toBeDefined();

		const res = await request(app)
			.get('/oauth/userinfo')
			.set('Authorization', `Bearer ${accessToken}`);

		expect(res.status).toBe(200);
		expect(res.body).toHaveProperty('sub');
		// profile scope
		expect(res.body).toHaveProperty('name');
		expect(res.body).toHaveProperty('preferred_username');
		// email scope
		expect(res.body).toHaveProperty('email');
	});

	test('rejects missing Bearer token with 401', async () => {
		const res = await request(app).get('/oauth/userinfo');
		expect(res.status).toBe(401);
	});

	test('rejects invalid Bearer token with 401', async () => {
		const res = await request(app)
			.get('/oauth/userinfo')
			.set('Authorization', 'Bearer not-a-real-token');
		expect(res.status).toBe(401);
	});
});

describe('OAuth — POST /oauth/token (refresh_token grant)', () => {
	test('exchanges refresh token for new access + refresh tokens', async () => {
		expect(refreshToken).toBeDefined();

		const res = await request(app)
			.post('/oauth/token')
			.type('form')
			.send({
				grant_type: 'refresh_token',
				refresh_token: refreshToken,
				client_id: clientId,
				client_secret: clientSecret,
			});

		expect(res.status).toBe(200);
		expect(res.body).toHaveProperty('access_token');
		expect(res.body).toHaveProperty('refresh_token');
		// New tokens should be different (rotation)
		expect(res.body.access_token).not.toBe(accessToken);
		expect(res.body.refresh_token).not.toBe(refreshToken);

		accessToken = res.body.access_token;
		refreshToken = res.body.refresh_token;
	});

	test('rejects reuse of the old refresh token after rotation', async () => {
		// Capture the pre-rotation refresh token — it was rotated in the previous test
		// We need a fresh sequence for this test
		const { challenge, verifier } = generatePKCE();
		const codeRes = await request(app)
			.post('/api/oauth/authorize')
			.set('auth-token', token)
			.send({
				response_type: 'code',
				client_id: clientId,
				redirect_uri: REDIRECT_URI,
				scope: 'openid',
				code_challenge: challenge,
				code_challenge_method: 'S256',
			});
		const freshCode = new URL(codeRes.body.redirect_url).searchParams.get('code');

		const tokenRes = await request(app)
			.post('/oauth/token')
			.type('form')
			.send({
				grant_type: 'authorization_code',
				code: freshCode,
				redirect_uri: REDIRECT_URI,
				client_id: clientId,
				client_secret: clientSecret,
				code_verifier: verifier,
			});
		const oldRefresh = tokenRes.body.refresh_token;

		// Rotate it once
		await request(app)
			.post('/oauth/token')
			.type('form')
			.send({ grant_type: 'refresh_token', refresh_token: oldRefresh, client_id: clientId, client_secret: clientSecret });

		// Try to reuse the old one
		const reuseRes = await request(app)
			.post('/oauth/token')
			.type('form')
			.send({ grant_type: 'refresh_token', refresh_token: oldRefresh, client_id: clientId, client_secret: clientSecret });

		expect(reuseRes.status).toBe(400);
		expect(reuseRes.body.error).toBe('invalid_grant');
	});
});

describe('OAuth — groups claim', () => {
	test('userinfo includes a groups array when the groups scope is granted', async () => {
		const { challenge, verifier } = generatePKCE();

		const codeRes = await request(app)
			.post('/api/oauth/authorize')
			.set('auth-token', token)
			.send({
				response_type: 'code',
				client_id: clientId,
				redirect_uri: REDIRECT_URI,
				scope: 'openid groups',
				code_challenge: challenge,
				code_challenge_method: 'S256',
			});
		const code = new URL(codeRes.body.redirect_url).searchParams.get('code');

		const tokRes = await request(app)
			.post('/oauth/token')
			.type('form')
			.send({
				grant_type: 'authorization_code',
				code,
				redirect_uri: REDIRECT_URI,
				client_id: clientId,
				client_secret: clientSecret,
				code_verifier: verifier,
			});
		expect(tokRes.status).toBe(200);

		const uiRes = await request(app)
			.get('/oauth/userinfo')
			.set('Authorization', 'Bearer ' + tokRes.body.access_token);
		expect(uiRes.status).toBe(200);
		expect(Array.isArray(uiRes.body.groups)).toBe(true);
	});
});

describe('OAuth — allowed_groups access control', () => {
	let restrictedId;

	beforeAll(async () => {
		const res = await request(app)
			.post('/api/oauth/client/')
			.set('auth-token', token)
			.send({
				name: 'Restricted Group Test',
				redirect_uris: REDIRECT_URI,
				scopes: 'openid',
				allowed_groups: 'this_group_does_not_exist_xyz',
			});
		restrictedId = res.body.results && res.body.results.client_id;
	});

	afterAll(async () => {
		if (restrictedId) {
			await request(app).delete('/api/oauth/client/' + restrictedId).set('auth-token', token);
		}
	});

	test('denies a user who is not in any allowed group (403)', async () => {
		const { challenge } = generatePKCE();
		const res = await request(app)
			.post('/api/oauth/authorize')
			.set('auth-token', token)
			.send({
				response_type: 'code',
				client_id: restrictedId,
				redirect_uri: REDIRECT_URI,
				scope: 'openid',
				code_challenge: challenge,
				code_challenge_method: 'S256',
			});
		expect(res.status).toBe(403);
	});
});
