'use strict';

const crypto = require('crypto');
const express = require('express');
const Database = require('../storage/database');

/**
 * OpenID Connect (authorization-code + PKCE) login for the web dashboard,
 * modeled on theta42/proxy's auth flow.
 *
 * - Config lives in the settings manager under auth.* (SSO endpoints, client
 *   credentials, allowed users/groups). auth.enabled=false (default) leaves
 *   the dashboard open exactly as before.
 * - Sessions are opaque random tokens in the storage sqlite DB, delivered as
 *   an HttpOnly SameSite=Lax cookie so the existing dashboard fetch() calls
 *   work unchanged. API clients may instead send the token in an
 *   `auth-token` header.
 * - The in-flight OIDC state (PKCE verifier + post-login redirect) is held
 *   in memory with a 5-minute TTL — single process, no cleanup job needed.
 *
 * Identity is read from the SSO's userinfo endpoint server-side; ID-token
 * signatures are not verified (same trade-off as the reference impl).
 */

const COOKIE_NAME = 'mcbt_session';
const STATE_TTL_MS = 5 * 60 * 1000;

// ========================================
// Config
// ========================================

function authConf() {
	const settings = require('../settings/manager');
	return {
		enabled: settings.get('auth.enabled') === true,
		authorizationEndpoint: settings.get('auth.authorizationEndpoint'),
		tokenEndpoint: settings.get('auth.tokenEndpoint'),
		userinfoEndpoint: settings.get('auth.userinfoEndpoint'),
		clientId: settings.get('auth.clientId'),
		clientSecret: settings.get('auth.clientSecret'),
		redirectUri: settings.get('auth.redirectUri'),
		scopes: settings.get('auth.scopes') || ['openid', 'profile', 'email', 'groups'],
		usernameClaim: settings.get('auth.usernameClaim') || 'preferred_username',
		groupsClaim: settings.get('auth.groupsClaim') || 'groups',
		allowedUsers: settings.get('auth.allowedUsers') || [],
		allowedGroups: settings.get('auth.allowedGroups') || [],
		tokenTTL: settings.get('auth.tokenTTL') || 30 * 24 * 3600, // seconds
	};
}

// ========================================
// Small helpers (ported from theta42/proxy)
// ========================================

const base64url = buf => buf.toString('base64')
	.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

function randomToken(bytes = 32) {
	return base64url(crypto.randomBytes(bytes));
}

function codeChallengeS256(verifier) {
	return base64url(crypto.createHash('sha256').update(verifier).digest());
}

/**
 * Constrain a post-login redirect target to a same-origin path.
 * Rejects absolute URLs, protocol-relative ("//evil.com"), and scheme
 * targets ("javascript:..."). Anything not a plain "/path" becomes "/".
 */
function safeInternalPath(path) {
	if (typeof path !== 'string' || path.charAt(0) !== '/'
			|| path.charAt(1) === '/' || path.charAt(1) === '\\') {
		return '/';
	}
	return path;
}

/** Minimal per-IP fixed-window rate limiter (no external dependency). */
function rateLimiter(max = 60, windowMs = 15 * 60 * 1000) {
	const hits = new Map();
	return (req, res, next) => {
		const now = Date.now();
		const ip = req.ip || req.socket.remoteAddress || 'unknown';
		let rec = hits.get(ip);
		if (!rec || now > rec.reset) {
			rec = { count: 0, reset: now + windowMs };
			hits.set(ip, rec);
		}
		if (++rec.count > max) {
			return res.status(429).json({ error: 'Too many attempts, please try again later.' });
		}
		if (hits.size > 1000) {
			for (const [k, v] of hits) if (now > v.reset) hits.delete(k);
		}
		next();
	};
}

function parseCookies(req) {
	const header = req.headers.cookie;
	if (!header) return {};
	const out = {};
	for (const part of header.split(';')) {
		const idx = part.indexOf('=');
		if (idx === -1) continue;
		out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
	}
	return out;
}

function isSecureRequest(req) {
	return req.secure || req.headers['x-forwarded-proto'] === 'https';
}

// ========================================
// OIDC client
// ========================================

function createAuthRequest() {
	const state = randomToken(32);
	const codeVerifier = randomToken(32);
	return { state, codeVerifier, codeChallenge: codeChallengeS256(codeVerifier) };
}

function buildAuthUrl(state, codeChallenge) {
	const o = authConf();
	const params = new URLSearchParams({
		response_type: 'code',
		client_id: o.clientId,
		redirect_uri: o.redirectUri,
		scope: o.scopes.join(' '),
		state,
		code_challenge: codeChallenge,
		code_challenge_method: 'S256',
	});
	return `${o.authorizationEndpoint}?${params.toString()}`;
}

async function exchangeCode(code, codeVerifier) {
	const o = authConf();
	const body = new URLSearchParams({
		grant_type: 'authorization_code',
		code,
		redirect_uri: o.redirectUri,
		client_id: o.clientId,
		client_secret: o.clientSecret,
		code_verifier: codeVerifier,
	});

	const res = await fetch(o.tokenEndpoint, {
		method: 'POST',
		headers: {
			'Content-Type': 'application/x-www-form-urlencoded',
			'Accept': 'application/json',
		},
		body: body.toString(),
	});

	if (!res.ok) {
		const text = await res.text().catch(() => '');
		throw new Error(`Token exchange failed (${res.status}): ${text.slice(0, 200)}`);
	}
	return res.json();
}

async function fetchUserInfo(accessToken) {
	const o = authConf();
	const res = await fetch(o.userinfoEndpoint, {
		headers: {
			'Authorization': `Bearer ${accessToken}`,
			'Accept': 'application/json',
		},
	});
	if (!res.ok) throw new Error(`Userinfo request failed (${res.status})`);
	return res.json();
}

function claimsToIdentity(claims) {
	const o = authConf();
	const username = claims[o.usernameClaim] || claims.sub;
	let groups = claims[o.groupsClaim] || [];
	if (!Array.isArray(groups)) groups = [groups].filter(Boolean);
	return { username, groups };
}

/** allowedUsers / allowedGroups gate — both empty means any SSO user. */
function identityAllowed(identity) {
	const o = authConf();
	const users = (o.allowedUsers || []).map(u => String(u).toLowerCase());
	const groups = (o.allowedGroups || []).map(g => String(g).toLowerCase());
	if (users.length === 0 && groups.length === 0) return true;
	if (users.includes(String(identity.username).toLowerCase())) return true;
	return identity.groups.some(g => groups.includes(String(g).toLowerCase()));
}

// ========================================
// One-time OIDC state store (in-memory, TTL)
// ========================================

const _states = new Map(); // state -> { codeVerifier, redirect, expires }

function saveState(state, data) {
	_states.set(state, { ...data, expires: Date.now() + STATE_TTL_MS });
	// Opportunistic sweep of expired/abandoned logins
	for (const [k, v] of _states) if (Date.now() > v.expires) _states.delete(k);
}

/** Consume a state record — one-time use bounds replay. */
function takeState(state) {
	const rec = _states.get(state);
	if (!rec) return null;
	_states.delete(state);
	if (Date.now() > rec.expires) return null;
	return rec;
}

// ========================================
// Session token store (sqlite)
// ========================================

let _tableReady = false;
async function ensureTable() {
	if (_tableReady) return;
	await Database.db.run(`
		CREATE TABLE IF NOT EXISTS auth_tokens (
			token TEXT PRIMARY KEY,
			username TEXT NOT NULL,
			groups TEXT DEFAULT '[]',
			created_at INTEGER NOT NULL,
			expires_at INTEGER NOT NULL
		)
	`);
	_tableReady = true;
}

async function createSession(identity) {
	await ensureTable();
	const token = randomToken(32);
	const now = Date.now();
	await Database.db.run(
		'INSERT INTO auth_tokens (token, username, groups, created_at, expires_at) VALUES (?, ?, ?, ?, ?)',
		[token, identity.username, JSON.stringify(identity.groups || []), now, now + authConf().tokenTTL * 1000]
	);
	// Opportunistic cleanup of expired sessions
	await Database.db.run('DELETE FROM auth_tokens WHERE expires_at < ?', [now]);
	return token;
}

async function checkSession(token) {
	if (!token) return null;
	await ensureTable();
	const row = await Database.db.get('SELECT * FROM auth_tokens WHERE token = ?', [token]);
	if (!row) return null;
	if (row.expires_at < Date.now()) {
		await Database.db.run('DELETE FROM auth_tokens WHERE token = ?', [token]);
		return null;
	}
	return { username: row.username, groups: JSON.parse(row.groups || '[]') };
}

async function destroySession(token) {
	if (!token) return;
	await ensureTable();
	await Database.db.run('DELETE FROM auth_tokens WHERE token = ?', [token]);
}

// ========================================
// Middleware
// ========================================

function readToken(req) {
	return parseCookies(req)[COOKIE_NAME] || req.header('auth-token') || null;
}

/**
 * Gate every route behind a session when auth.enabled. Browsers get a
 * redirect to the login page; API callers get a 401.
 */
async function middleware(req, res, next) {
	try {
		if (!authConf().enabled) return next();
		if (req.path === '/health' || req.path === '/auth' || req.path.startsWith('/auth/')) return next();

		const session = await checkSession(readToken(req));
		if (session) {
			req.user = session.username;
			req.groups = session.groups;
			return next();
		}

		if (req.method === 'GET' && req.accepts(['json', 'html']) === 'html') {
			return res.redirect('/auth/login?redirect=' + encodeURIComponent(safeInternalPath(req.originalUrl)));
		}
		return res.status(401).json({ error: 'Authentication required' });
	} catch (error) {
		next(error);
	}
}

// ========================================
// Router
// ========================================

function createRouter() {
	const router = express.Router();
	const limiter = rateLimiter(60, 15 * 60 * 1000);

	router.get('/login', (req, res) => {
		const redirect = safeInternalPath(req.query.redirect || '/');
		const error = req.query.error ? String(req.query.error).slice(0, 200) : null;
		res.send(loginPageHTML(redirect, error));
	});

	// OIDC login start: create a PKCE + state challenge, stash it, redirect
	// the browser to the SSO authorize endpoint.
	router.get('/oidc/start', limiter, (req, res) => {
		const o = authConf();
		if (!o.enabled) return res.status(404).json({ error: 'Auth is not enabled' });
		if (!o.authorizationEndpoint || !o.clientId) {
			return res.status(500).json({ error: 'OIDC is not configured (auth.authorizationEndpoint / auth.clientId)' });
		}

		const { state, codeVerifier, codeChallenge } = createAuthRequest();
		saveState(state, {
			codeVerifier,
			// Sanitize now so a hostile ?redirect= can't be stored and later
			// reflected into navigation.
			redirect: safeInternalPath(req.query.redirect || '/'),
		});

		res.redirect(buildAuthUrl(state, codeChallenge));
	});

	// OIDC callback: validate + consume state, exchange the code, read
	// identity from userinfo, set the session cookie, redirect into the app.
	router.get('/oidc/callback', limiter, async (req, res) => {
		try {
			const { code, state } = req.query;
			if (!code || !state) throw new Error('Missing code or state');

			const saved = takeState(String(state));
			if (!saved) throw new Error('Unknown or expired login attempt — try again');

			const tokens = await exchangeCode(String(code), saved.codeVerifier);
			const claims = await fetchUserInfo(tokens.access_token);
			const identity = claimsToIdentity(claims);

			if (!identityAllowed(identity)) {
				console.log(`Auth: DENIED login for '${identity.username}' (groups: ${identity.groups.join(', ') || 'none'})`);
				return res.redirect('/auth/login?error=' + encodeURIComponent(`Account '${identity.username}' is not authorized for this dashboard.`));
			}

			const token = await createSession(identity);
			console.log(`Auth: '${identity.username}' logged in`);

			const flags = [
				`${COOKIE_NAME}=${encodeURIComponent(token)}`,
				'HttpOnly', 'Path=/', 'SameSite=Lax',
				`Max-Age=${authConf().tokenTTL}`,
			];
			if (isSecureRequest(req)) flags.push('Secure');
			res.setHeader('Set-Cookie', flags.join('; '));

			res.redirect(safeInternalPath(saved.redirect || '/'));
		} catch (error) {
			console.error('Auth: OIDC callback error:', error.message);
			res.redirect('/auth/login?error=' + encodeURIComponent(error.message));
		}
	});

	router.all('/logout', async (req, res) => {
		try {
			await destroySession(readToken(req));
		} catch (error) {
			console.error('Auth: logout error:', error.message);
		}
		res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0`);
		if (req.accepts(['json', 'html']) === 'html') return res.redirect('/auth/login');
		res.json({ message: 'Bye' });
	});

	// Who am I — lets the UI show the logged-in user
	router.get('/me', async (req, res) => {
		if (!authConf().enabled) return res.json({ enabled: false });
		const session = await checkSession(readToken(req));
		if (!session) return res.status(401).json({ enabled: true, error: 'Not logged in' });
		res.json({ enabled: true, username: session.username, groups: session.groups });
	});

	return router;
}

function escapeHtml(s) {
	return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
		.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

function loginPageHTML(redirect, error) {
	return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MC Bot Town — Login</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',Tahoma,sans-serif;background:#111827;color:#e5e7eb;min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{background:#1f2937;border:1px solid #374151;border-radius:12px;padding:40px;width:360px;text-align:center}
.card h1{font-size:1.3em;color:#60a5fa;margin-bottom:8px}
.card p{color:#9ca3af;font-size:.9em;margin-bottom:24px}
.sso-btn{display:block;width:100%;background:#2563eb;color:#fff;border:none;padding:12px;border-radius:8px;font-size:1em;cursor:pointer;text-decoration:none}
.sso-btn:hover{background:#1d4ed8}
.error{background:#7f1d1d;border:1px solid #dc2626;color:#fecaca;padding:10px;border-radius:8px;font-size:.85em;margin-bottom:16px}
</style>
</head>
<body>
<div class="card">
	<h1>MC Bot Town</h1>
	<p>Sign in to manage the bot fleet</p>
	${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
	<a class="sso-btn" href="/auth/oidc/start?redirect=${encodeURIComponent(redirect)}">Sign in with SSO</a>
</div>
</body>
</html>`;
}

module.exports = {
	middleware,
	createRouter,
	authConf,
	safeInternalPath,
	checkSession,
};
