'use strict';

// Vault broker — mints scoped OpenBao tokens for end users, admins, and
// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the
// `sso-broker` token role created by theta-env/setup.sh.
//
//   secret/users/<uid>/*          per-user personal KV (user-<uid> policy)
//   secret/shared/<uid>/*         user-owned shared KV (user-<uid> policy)
//   secret/apps/<name>/*          per-external-app namespace (app-<name> policy)
//   secret/shared/<owner>/<slug>  granted read (added to grantee's policy)
//   secret/*                      admin UI sessions (sso-admin policy)
//
// The sso-broker policy grants update on auth/token/create/sso-broker and on
// sys/policies/acl/user-*, app-*, sso-admin — exactly what this module needs to
// create the per-subject policies and mint their tokens. Per-user/admin tokens
// are cached in Redis for the token's lifetime and re-minted on miss; per-app
// tokens are returned ONCE (displayed in the UI, never stored retrievably).
//
// Policy reconciliation is the load-bearing part: OpenBao parses policy CONTENT
// live at token use (only the SET of policy names on a token is fixed at mint),
// so we ALWAYS reconcile a subject's policy content BEFORE returning any token
// — cached or freshly minted. That way a stale cached token immediately gains
// corrected/revoked capabilities, and a new shared-secret grant takes effect for
// an existing grantee token with no re-mint. The Redis cache only short-circuits
// token MINTING, never policy reconciliation.

const baoConf = require('@simpleworkjs/bao-conf');
const { createClient } = require('redis');
const express = require('express');
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
const conf = require('@simpleworkjs/conf');
const permission = require('./permission');
const { SharedSecret } = require('../models/shared_secret');
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const { VaultAppToken } = require('../models/vault_app_token');

const ROLE = 'sso-broker';
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)

let redisClient;
async function getRedis() {
	if (!redisClient) {
		const url = (conf.redis && typeof conf.redis === 'string') ? conf.redis
			: (conf.redis && conf.redis.url) ? conf.redis.url : undefined;
		redisClient = createClient({ url });
		redisClient.on('error', (err) => console.error('Redis vault_broker error', err));
		await redisClient.connect();
	}
	return redisClient;
}

async function cacheGet(key) {
	try { return await (await getRedis()).get(key); } catch (e) { return null; }
}
async function cacheSet(key, value, ttl) {
	try { await (await getRedis()).set(key, value, { EX: ttl }); } catch (e) { /* best-effort */ }
}

// Low-level OpenBao call via @simpleworkjs/bao-conf.request (authenticates with
// SSO_VAULT_TOKEN). Throws on non-2xx.
async function bao(method, path, body) {
	const res = await baoConf.request(method, path, body);
	if (!res.ok) {
		const text = await res.text().catch(() => '');
		throw new Error(`OpenBao ${method} ${path} failed (${res.status}) ${text}`);
	}
	return res;
}

// Ensure an ACL policy carries exactly `hcl`. Compare-and-skip: read the current
// content and only PUT when it differs. `bao policy write` is an idempotent
// overwrite, so this is safe to call on every token fetch — edits (e.g. adding a
// grant) propagate immediately because OpenBao parses policy content at use.
async function ensurePolicy(name, hcl) {
	try {
		const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
		if (existing.status === 200) {
			const body = await existing.json().catch(() => null);
			if (body && typeof body.policy === 'string' && body.policy.trim() === hcl.trim()) return; // unchanged
		}
	} catch (e) {
		console.warn(`[VaultBroker] policy GET ${name} warning:`, e.message);
	}
	try {
		await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
	} catch (err) {
		console.warn(`[VaultBroker] policy PUT ${name} warning:`, err.message);
	}
}

// Mint a token through a token role with the given policies. Returns
// { token, accessor, ttl } (ttl = lease_duration seconds, falls back to
// DEFAULT_TTL). Roles: sso-broker (24h period — user/admin tokens, re-minted
// from cache) and sso-app (768h period — long-lived external-app credentials,
// kept alive via their stored accessor by the renewal loop below).
async function mintToken(policies, role = ROLE) {
	const res = await bao('POST', `auth/token/create/${role}`, { policies });
	const json = await res.json();
	const token = json && json.auth && json.auth.client_token;
	if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`);
	const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL;
	return { token, accessor: json.auth.accessor, ttl };
}

// ── Shared-secret policy rules ───────────────────────────────────────────────
// Returns the HCL rules granting `read` on every shared secret the given
// grantee (a user uid or an app name) has been granted. Enforcement is
// OpenBao ACL policy CONTENT — live-evaluated at token use, so these rules take
// effect for the grantee's existing token immediately (no re-mint).
async function sharedPolicyRules(granteeType, granteeId) {
	const grants = await SharedSecretGrant.listForGrantee(granteeType, granteeId);
	if (!grants.length) return '';
	const secretIds = [...new Set(grants.map(g => g.secretId))];
	const secrets = secretIds.length
		? await SharedSecret.list({ where: { id: { in: secretIds } } }) : [];
	const byId = new Map(secrets.map(s => [s.id, s]));
	const rules = [];
	for (const g of grants) {
		const sec = byId.get(g.secretId);
		if (!sec) continue;
		const p = sec.path(); // shared/<ownerUid>/<slug>
		rules.push(`path "secret/data/${p}" { capabilities = ["read"] }`);
		rules.push(`path "secret/metadata/${p}" { capabilities = ["read", "list"] }`);
	}
	return rules.join('\n');
}

// ── Per-user token ──────────────────────────────────────────────────────────
async function userPolicyHcl(uid) {
	const granted = await sharedPolicyRules('user', uid);
	return `path "secret/data/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
${granted}`.trim();
}

// Mint (or return the cached) per-user token. The policy is ALWAYS reconciled
// (compare-and-skip) before the cache is consulted, so a cached token can never
// outlive a policy change; the cache only short-circuits re-minting. Re-minted
// when the cache entry expires (a little before the token's own TTL).
async function getOrCreateUserToken(uid) {
	if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${uid}`);
	await ensurePolicy(`user-${uid}`, await userPolicyHcl(uid));
	const cacheKey = `vault_token:${uid}`;
	const cached = await cacheGet(cacheKey);
	if (cached) return cached;
	const { token, ttl } = await mintToken([`user-${uid}`]);
	await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
	return token;
}

// ── Admin token (read/write all of secret/) ─────────────────────────────────
function adminPolicyHcl() {
	return `path "secret/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", "list"] }`;
}

async function getOrCreateAdminToken(uid) {
	await ensurePolicy('sso-admin', adminPolicyHcl());
	const cacheKey = `vault_token:admin:${uid || 'global'}`;
	const cached = await cacheGet(cacheKey);
	if (cached) return cached;
	const { token, ttl } = await mintToken(['sso-admin']);
	await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
	return token;
}

// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
async function appPolicyHcl(name) {
	const granted = await sharedPolicyRules('app', name);
	return `path "secret/data/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
${granted}`.trim();
}

// Create the app-<name> policy + mint a token for it. Returns the token ONCE
// (the admin UI shows it with a copy button); it is not stored retrievably, so
// a later compromise of an admin session cannot recover previously-minted app
// tokens. The caller must record it in the external app immediately. Later
// grants to the app edit app-<name> policy content (live-applied to this token).
//
// What IS stored is the token's ACCESSOR (VaultAppToken row): an accessor
// cannot authenticate, but it lets the renewal loop below keep the (periodic)
// token alive and lets a re-mint revoke the app's previous token so exactly
// one credential per app is ever live.
async function mintAppToken(name, actorUid) {
	if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) {
		throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)');
	}
	await ensurePolicy(`app-${name}`, await appPolicyHcl(name));
	// App tokens are long-lived credentials: mint via the sso-app role (768h
	// period) so a renewal inside every 32-day window keeps them alive forever.
	// Fall back to the broker's own 24h role on deployments whose setup.sh
	// predates the sso-app role (re-running setup.sh creates it).
	let minted;
	try {
		minted = await mintToken([`app-${name}`], 'sso-app');
	} catch (e) {
		console.warn(`vault_broker: sso-app token role unavailable (${e.message}); falling back to sso-broker (24h period). Re-run theta-env setup.sh to create the sso-app role.`);
		minted = await mintToken([`app-${name}`]);
	}
	const { token, accessor, ttl } = minted;
	// Replace the app's accessor row; revoke the superseded token (best-effort —
	// it may already be expired) so re-minting never leaves a zombie credential.
	try {
		const existing = await VaultAppToken.getByName(name);
		if (existing) {
			await baoConf.request('POST', 'auth/token/revoke-accessor', { accessor: existing.accessor });
			await existing.delete();
		}
		if (accessor) {
			await VaultAppToken.create({
				name, accessor,
				lastRenewedAt: Date.now(),
				created_by: actorUid, created_on: Date.now(),
			});
		}
	} catch (e) {
		// Accessor bookkeeping must never block handing the token out; without a
		// row the token simply isn't auto-renewed (it still lives one full period).
		console.error(`vault_broker: could not store accessor for app-${name}:`, e.message);
	}
	return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
}

// ── App-token renewal loop ──────────────────────────────────────────────────
// Walks the stored accessors and renews each token (auth/token/renew-accessor),
// resetting its periodic clock. Runs at boot and then every RENEW_INTERVAL_MS —
// far inside both possible periods (24h fallback and 768h), so a downstream
// app's token stays valid for as long as sso is running. Failures are recorded
// on the row (visible to admins in the DB / future UI) and never throw.
const RENEW_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h — several chances per 24h period
let renewTimer;

async function renewAppTokens() {
	let rows;
	try { rows = await VaultAppToken.list(); }
	catch (e) { console.error('vault_broker: app-token renewal: could not list accessors:', e.message); return; }
	for (const row of rows) {
		try {
			const res = await baoConf.request('POST', 'auth/token/renew-accessor', { accessor: row.accessor });
			if (res.ok) {
				await row.update({ lastRenewedAt: Date.now(), lastError: null });
			} else {
				const text = await res.text().catch(() => '');
				// 400 "invalid accessor" = token expired or was revoked out-of-band;
				// keep the row + error so the admin can see the app needs a re-mint.
				await row.update({ lastError: `renew failed (${res.status}) ${text}` });
				console.warn(`vault_broker: renew of app token '${row.name}' failed (${res.status}) — re-mint it from the vault UI if the app is still in use.`);
			}
		} catch (e) {
			try { await row.update({ lastError: e.message }); } catch (e2) { /* best-effort */ }
			console.error(`vault_broker: renew of app token '${row.name}' errored:`, e.message);
		}
	}
}

// Start the loop (idempotent). unref() so an open handle never blocks exit.
function startAppTokenRenewal() {
	if (renewTimer) return renewTimer;
	renewAppTokens().catch((e) => console.error('vault_broker: initial app-token renewal failed:', e.message));
	renewTimer = setInterval(() => {
		renewAppTokens().catch((e) => console.error('vault_broker: app-token renewal failed:', e.message));
	}, RENEW_INTERVAL_MS);
	if (renewTimer.unref) renewTimer.unref();
	return renewTimer;
}

// ── Grant / revoke shared-secret access ─────────────────────────────────────
// Creating a grant writes the DB row and then edits the grantee's policy content
// to add read on the shared path; revoking removes both. Because OpenBao parses
// policy content live, the change applies to the grantee's existing token
// immediately — no token re-mint, no cache invalidation needed.
async function grantSharedSecret(secretId, granteeType, granteeId, actorUid) {
	const grant = await SharedSecretGrant.create({
		secretId, granteeType, granteeId, capability: 'read',
		created_by: actorUid, created_on: Date.now(),
		updated_by: actorUid, updated_on: Date.now(),
	});
	await reconcileGrantee(granteeType, granteeId);
	return grant;
}

async function revokeSharedSecret(grantId, actorUid) {
	const grant = await SharedSecretGrant.get(grantId);
	if (!grant) return null;
	const { granteeType, granteeId } = grant;
	await grant.delete();
	await reconcileGrantee(granteeType, granteeId);
	return grant;
}

// Recompute and rewrite a grantee's policy content after a grant/revoke.
async function reconcileGrantee(granteeType, granteeId) {
	if (granteeType === 'user') {
		await ensurePolicy(`user-${granteeId}`, await userPolicyHcl(granteeId));
	} else if (granteeType === 'app') {
		await ensurePolicy(`app-${granteeId}`, await appPolicyHcl(granteeId));
	} else {
		throw new Error(`invalid granteeType: ${granteeType}`);
	}
}

// ── /api/vault proxy: scope guard + token-injecting proxy ───────────────────
// Replaces the old bare pass-through (which sent no X-Vault-Token and gated
// nothing). The guard mints a server-side token for the user (per-user or
// admin) and enforces the path prefix as defense-in-depth on top of the
// token's own policy; the proxy injects ONLY that token and strips the
// client's sso auth headers so OpenBao never sees them.

const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
const ADMIN_GROUP = 'app_sso_admin';

async function isAdmin(user) {
	try {
		await permission.byGroup(user, ADMIN_GROUPS);
		return true;
	} catch (e) {
		return false;
	}
}

// Normalize a KV-v2 request path by stripping the data/metadata segment so the
// prefix check works on the logical path: /secret/data/users/alice/foo ->
// /secret/users/alice/foo. Returns null if the path isn't under /secret/.
function normalizeVaultPath(p) {
	const norm = p.replace(/^\/secret\/(data|metadata)\//, '/secret/');
	if (norm !== '/secret' && !norm.startsWith('/secret/')) return null;
	return norm;
}

async function scopeGuard(req, res, next) {
	if (!req.user || req.user.isMachine) {
		return res.status(403).json({ error: 'machine tokens cannot use the vault API' });
	}
	const uid = req.user.uid;
	const admin = await isAdmin(req.user);
	let token;
	try {
		token = admin ? await getOrCreateAdminToken(uid) : await getOrCreateUserToken(uid);
	} catch (e) {
		return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
	}

	const norm = normalizeVaultPath(req.path);
	if (norm === null) {
		return res.status(403).json({ error: 'vault paths must be under /secret/' });
	}
	const userBase = `/secret/users/${uid}`;
	const sharedBase = `/secret/shared`;
	const allowed = admin || norm === userBase || norm.startsWith(userBase + '/') || norm === sharedBase || norm.startsWith(sharedBase + '/');
	if (!allowed) {
		return res.status(403).json({ error: 'path outside your vault namespace' });
	}

	req.vaultToken = token;
	req.vaultIsAdmin = admin;
	next();
}

function vaultProxy() {
	return createProxyMiddleware({
		target: VAULT_ADDR,
		changeOrigin: true,
		pathRewrite: { '^/api/vault': '/v1' },
		// http-proxy-middleware v2 API: hooks are top-level onProxyReq/onError,
		// NOT the v3 `on: { proxyReq }` shape. v2 silently ignores an `on` key,
		// which shipped this proxy with NO token injection — every /api/vault
		// call reached OpenBao unauthenticated and 403'd.
		onProxyReq(proxyReq, req, res, options) {
			// Header ops MUST precede fixRequestBody: it write()s the parsed body
			// onto proxyReq, which flushes headers — setHeader after that throws
			// (swallowed upstream), silently dropping the token on every write.
			// Inject ONLY the server-minted scoped token; strip the client's
			// sso session/api auth so it never reaches OpenBao.
			proxyReq.setHeader('X-Vault-Token', req.vaultToken);
			proxyReq.removeHeader('auth-token');
			proxyReq.removeHeader('authorization');
			fixRequestBody(proxyReq, req, res, options);
		},
	});
}

// Admin-only: mint a one-time token for an external app. POST /api/vault/apps
// { name } -> { token, ttl, policy, path }. The token is returned ONCE and is
// not cached/stored retrievably. Mount BEFORE the /api/vault proxy.
const mintAppRouter = express.Router();
mintAppRouter.post('/', async (req, res, next) => {
	try {
		await permission.byGroup(req.user, [ADMIN_GROUP]);
		const name = (req.body && req.body.name || '').trim();
		if (!name) return res.status(400).json({ error: 'name is required' });
		const result = await mintAppToken(name, req.user && req.user.uid);
		res.json(result);
	} catch (e) {
		if (e.status === 401) return res.status(403).json({ error: 'admin only' });
		next(e);
	}
});

// List the minted external-app tokens (metadata only — the token itself is shown
// once at mint and never stored; the accessor is a renewal/revoke handle and is
// never exposed). Lets the Apps tab show what has been minted instead of a
// credential vanishing into the void.
mintAppRouter.get('/', async (req, res, next) => {
	try {
		await permission.byGroup(req.user, [ADMIN_GROUP]);
		const rows = await VaultAppToken.list();
		res.json({ apps: rows.map((r) => ({
			name: r.name,
			createdBy: r.created_by,
			createdOn: r.created_on,
			lastRenewedAt: r.lastRenewedAt || null,
			lastError: r.lastError || null,
		})) });
	} catch (e) {
		if (e.status === 401) return res.status(403).json({ error: 'admin only' });
		next(e);
	}
});

module.exports = {
	getOrCreateUserToken,
	getOrCreateAdminToken,
	mintAppToken,
	ensurePolicy,
	scopeGuard,
	vaultProxy,
	mintAppRouter,
	// app-token lifecycle
	renewAppTokens,
	startAppTokenRenewal,
	VaultAppToken,
	// sharing
	SharedSecret,
	SharedSecretGrant,
	userPolicyHcl,
	appPolicyHcl,
	grantSharedSecret,
	revokeSharedSecret,
	reconcileGrantee,
};
