#!/usr/bin/env node
'use strict';

/**
 * OIDC / auth-method smoke tests.
 *
 * Tests logic that does NOT require a live authentik instance:
 *  - PKCE generation
 *  - State sanitization
 *  - Role/group mapping
 *  - OIDC config validation
 *  - Admin auth-mode lockout logic
 *
 * Run: node scripts/test-oidc-smoke.js
 */

let passed = 0;
let failed = 0;
const tests = [];

function test(name, fn) {
  tests.push({ name, fn });
}

async function runTest({ name, fn }) {
  try {
    await fn();
    console.log(`  ✓  ${name}`);
    passed++;
  } catch (err) {
    console.error(`  ✗  ${name}`);
    console.error(`       ${err.message}`);
    failed++;
  }
}

function assert(condition, msg) {
  if (!condition) throw new Error(msg || 'Assertion failed');
}

function assertEqual(a, b, msg) {
  if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
}

const fs = require('fs');
const os = require('os');
const path = require('path');
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bill-tracker-oidc-'));
process.env.DB_PATH = path.join(tempDir, 'test.sqlite');

const { getDb, getSetting, setSetting, closeDb } = require('../db/database');
const oidcService = require('../services/oidcService');

function clearOidcEnv() {
  for (const key of [
    'OIDC_ENABLED',
    'OIDC_ISSUER_URL',
    'OIDC_CLIENT_ID',
    'OIDC_CLIENT_SECRET',
    'OIDC_REDIRECT_URI',
    'OIDC_SCOPES',
    'OIDC_ADMIN_GROUP',
    'OIDC_DEFAULT_ROLE',
    'OIDC_AUTO_PROVISION',
    'OIDC_PROVIDER_NAME',
  ]) {
    delete process.env[key];
  }
}

function resetOidcSettings() {
  setSetting('oidc_login_enabled', 'false');
  setSetting('oidc_provider_name', 'authentik');
  setSetting('oidc_issuer_url', '');
  setSetting('oidc_client_id', '');
  setSetting('oidc_client_secret', '');
  setSetting('oidc_token_auth_method', 'client_secret_basic');
  setSetting('oidc_redirect_uri', '');
  setSetting('oidc_scopes', 'openid email profile groups');
  setSetting('oidc_admin_group', '');
  setSetting('oidc_default_role', 'user');
  setSetting('oidc_auto_provision', 'true');
}

// ── PKCE helpers ──────────────────────────────────────────────────────────────

const crypto = require('crypto');

function generateCodeVerifier() {
  return crypto.randomBytes(32).toString('base64url');
}
function generateCodeChallenge(v) {
  return crypto.createHash('sha256').update(v).digest('base64url');
}

console.log('\nPKCE helpers');

test('code verifier is 43+ base64url chars', () => {
  const v = generateCodeVerifier();
  assert(v.length >= 43, `too short: ${v.length}`);
  assert(/^[A-Za-z0-9_-]+$/.test(v), 'invalid chars');
});

test('code challenge is SHA-256 of verifier (base64url)', () => {
  const v = generateCodeVerifier();
  const c = generateCodeChallenge(v);
  assert(c.length > 0, 'empty challenge');
  assert(/^[A-Za-z0-9_-]+$/.test(c), 'invalid chars');
});

test('different verifiers produce different challenges', () => {
  const c1 = generateCodeChallenge(generateCodeVerifier());
  const c2 = generateCodeChallenge(generateCodeVerifier());
  assert(c1 !== c2, 'challenges should differ');
});

// ── sanitizeRedirectTo ────────────────────────────────────────────────────────

const { sanitizeRedirectTo } = oidcService;

console.log('\nsanitizeRedirectTo');

test('allows simple paths', () => assertEqual(sanitizeRedirectTo('/dashboard'), '/dashboard'));
test('allows paths with query', () => assert(sanitizeRedirectTo('/a?b=c').startsWith('/a'), 'should allow'));
test('rejects protocol-relative //evil.com', () => assertEqual(sanitizeRedirectTo('//evil.com'), null));
test('rejects http:// external URL', () => assertEqual(sanitizeRedirectTo('http://evil.com'), null));
test('rejects javascript: URI', () => assertEqual(sanitizeRedirectTo('javascript:alert(1)'), null));
test('rejects null', () => assertEqual(sanitizeRedirectTo(null), null));
test('rejects empty string', () => assertEqual(sanitizeRedirectTo(''), null));
test('rejects non-string', () => assertEqual(sanitizeRedirectTo(42), null));
test('truncates at 200 chars', () => {
  const long = '/' + 'a'.repeat(300);
  const result = sanitizeRedirectTo(long);
  assert(result !== null && result.length <= 200, `expected ≤200, got ${result?.length}`);
});

// ── getOidcConfig ─────────────────────────────────────────────────────────────

const { getOidcConfig } = oidcService;

console.log('\ngetOidcConfig');

test('returns null when OIDC_ENABLED is not set', () => {
  clearOidcEnv();
  resetOidcSettings();
  const cfg = getOidcConfig();
  assertEqual(cfg, null);
});

test('returns null when required DB settings and env fallback are blank', () => {
  clearOidcEnv();
  resetOidcSettings();
  const cfg = getOidcConfig();
  assertEqual(cfg, null);
});

test('DB settings override env fallback', () => {
  clearOidcEnv();
  resetOidcSettings();
  process.env.OIDC_ISSUER_URL = 'https://env.example/application/o/bills/';
  process.env.OIDC_CLIENT_ID = 'env-client';
  process.env.OIDC_CLIENT_SECRET = 'env-secret';
  process.env.OIDC_REDIRECT_URI = 'https://env.example/callback';
  setSetting('oidc_issuer_url', 'https://db.example/application/o/bills/');
  setSetting('oidc_client_id', 'db-client');
  setSetting('oidc_client_secret', 'db-secret');
  setSetting('oidc_redirect_uri', 'https://db.example/callback');
  const cfg = getOidcConfig();
  assertEqual(cfg.issuerUrl, 'https://db.example/application/o/bills/');
  assertEqual(cfg.clientId, 'db-client');
  assertEqual(cfg.clientSecret, 'db-secret');
  assertEqual(cfg.tokenEndpointAuthMethod, 'client_secret_basic');
  assertEqual(cfg.redirectUri, 'https://db.example/callback');
});

test('token endpoint auth method can be configured as client_secret_basic', () => {
  clearOidcEnv();
  resetOidcSettings();
  setSetting('oidc_issuer_url', 'https://db.example/application/o/bills/');
  setSetting('oidc_client_id', 'db-client');
  setSetting('oidc_client_secret', 'db-secret');
  setSetting('oidc_redirect_uri', 'https://db.example/callback');
  setSetting('oidc_token_auth_method', 'client_secret_basic');
  const cfg = getOidcConfig();
  assertEqual(cfg.tokenEndpointAuthMethod, 'client_secret_basic');
});

test('env fallback works when DB settings are blank', () => {
  clearOidcEnv();
  resetOidcSettings();
  process.env.OIDC_ISSUER_URL = 'https://env.example/application/o/bills/';
  process.env.OIDC_CLIENT_ID = 'env-client';
  process.env.OIDC_CLIENT_SECRET = 'env-secret';
  process.env.OIDC_REDIRECT_URI = 'https://env.example/callback';
  const cfg = getOidcConfig();
  assertEqual(cfg.issuerUrl, 'https://env.example/application/o/bills/');
  assertEqual(cfg.clientId, 'env-client');
  assertEqual(cfg.clientSecret, 'env-secret');
});

test('does not return client_secret in normal usage check (secret stays server-side)', () => {
  // Config object has clientSecret but it's for server-side use — getPublicOidcInfo strips it
  const { getPublicOidcInfo } = oidcService;
  const info = getPublicOidcInfo();
  assert(!('clientSecret' in info), 'clientSecret must not appear in public info');
  assert(!('client_secret' in info), 'client_secret must not appear in public info');
});

test('Admin OIDC settings never return the client secret value', () => {
  clearOidcEnv();
  resetOidcSettings();
  setSetting('oidc_client_secret', 'stored-secret');
  const adminSettings = oidcService.getAdminOidcSettings();
  assertEqual(adminSettings.oidc_client_secret_set, true);
  assert(!('oidc_client_secret' in adminSettings), 'admin settings must not include raw secret');
  assert(!Object.values(adminSettings).includes('stored-secret'), 'raw secret leaked in admin settings');
});

test('OIDC configured is true only when required fields exist', () => {
  clearOidcEnv();
  resetOidcSettings();
  assertEqual(oidcService.getOidcConfigStatus().oidc_configured, false);
  setSetting('oidc_issuer_url', 'https://db.example/application/o/bills/');
  setSetting('oidc_client_id', 'db-client');
  setSetting('oidc_client_secret', 'db-secret');
  assertEqual(oidcService.getOidcConfigStatus().oidc_configured, false);
  setSetting('oidc_redirect_uri', 'https://app.example/api/auth/oidc/callback');
  assertEqual(oidcService.getOidcConfigStatus().oidc_configured, true);
});

test('OIDC login unavailable when enabled but config incomplete', () => {
  clearOidcEnv();
  resetOidcSettings();
  setSetting('oidc_login_enabled', 'true');
  setSetting('oidc_issuer_url', 'https://db.example/application/o/bills/');
  assertEqual(oidcService.isOidcLoginActive(), false);
  const publicInfo = oidcService.getPublicOidcInfo();
  assertEqual(publicInfo.oidc_enabled, false);
  assert(!('oidc_login_url' in publicInfo), 'login URL must be hidden while incomplete');
});

// ── Role mapping ──────────────────────────────────────────────────────────────

const { mapRoleFromClaims } = oidcService;

// Override getSetting to avoid DB dependency in tests
// mapRoleFromClaims reads from getSetting internally; we test with no admin group fallback
console.log('\nmapRoleFromClaims');

const noGroupConfig = { adminGroup: null, defaultRole: 'user' };
const adminGroupConfig = { adminGroup: 'bt-admins', defaultRole: 'user' };

test('defaults to user when no admin group configured', () => {
  assertEqual(mapRoleFromClaims({ groups: ['bt-admins', 'everyone'] }, noGroupConfig), 'user');
});

test('defaults to user when groups claim is absent', () => {
  assertEqual(mapRoleFromClaims({}, adminGroupConfig), 'user');
});

test('defaults to user when groups array is empty', () => {
  assertEqual(mapRoleFromClaims({ groups: [] }, adminGroupConfig), 'user');
});

test('grants admin when user is in admin group', () => {
  // Note: mapRoleFromClaims reads DB setting first; in test env getSetting returns null
  // so it falls back to config.adminGroup. This test validates the config-based path.
  const role = mapRoleFromClaims({ groups: ['bt-admins', 'users'] }, adminGroupConfig);
  assertEqual(role, 'admin');
});

test('default role setting cannot grant admin by default', () => {
  clearOidcEnv();
  resetOidcSettings();
  setSetting('oidc_default_role', 'admin');
  const role = mapRoleFromClaims({ groups: [] }, { adminGroup: null, defaultRole: 'admin' });
  assertEqual(role, 'user');
});

test('denies admin when user is NOT in admin group', () => {
  assertEqual(mapRoleFromClaims({ groups: ['users', 'billing'] }, adminGroupConfig), 'user');
});

test('handles non-array groups gracefully', () => {
  const role = mapRoleFromClaims({ groups: 'bt-admins' }, adminGroupConfig);
  // Single-string group — should still match
  assert(['user', 'admin'].includes(role), 'must return user or admin');
});

// ── getPublicOidcInfo — no secrets ────────────────────────────────────────────

console.log('\ngetPublicOidcInfo');

test('returns oidc_enabled:false when OIDC not configured', () => {
  const { getPublicOidcInfo } = oidcService;
  clearOidcEnv();
  resetOidcSettings();
  const info = getPublicOidcInfo();
  // OIDC is not configured in test env so should always be false here
  assert(!info.clientSecret, 'no clientSecret');
  assert(!info.client_secret, 'no client_secret');
  assert(!info.OIDC_CLIENT_SECRET, 'no env secret');
});

// ── Admin auth-mode lockout logic ─────────────────────────────────────────────
// Test the validation logic directly (without HTTP layer)

console.log('\nAdmin auth-mode lockout logic');

function simulateLockoutCheck(nextLocal, nextOidc, oidcConfigured, oidcAdminGroup = 'bt-admins') {
  if (!nextLocal && !nextOidc) return 'cannot_disable_all';
  if (!nextLocal && !oidcConfigured) return 'cannot_disable_local_without_oidc_configured';
  if (!nextLocal && !nextOidc) return 'cannot_disable_local_without_oidc_enabled';
  if (!nextLocal && !oidcAdminGroup) return 'cannot_disable_local_without_admin_group';
  return 'ok';
}

test('cannot disable local when OIDC is not configured', () => {
  assertEqual(simulateLockoutCheck(false, false, false), 'cannot_disable_all');
});

test('cannot disable local when OIDC configured but not enabled', () => {
  assertEqual(simulateLockoutCheck(false, false, true), 'cannot_disable_all');
});

test('cannot disable local if OIDC configured but would leave no methods', () => {
  assertEqual(simulateLockoutCheck(false, false, true), 'cannot_disable_all');
});

test('can disable local if OIDC configured AND enabled', () => {
  assertEqual(simulateLockoutCheck(false, true, true), 'ok');
});

test('cannot disable local without an OIDC admin group', () => {
  assertEqual(simulateLockoutCheck(false, true, true, ''), 'cannot_disable_local_without_admin_group');
});

test('can disable OIDC if local is still enabled', () => {
  assertEqual(simulateLockoutCheck(true, false, true), 'ok');
});

test('can disable OIDC if local is still enabled (no oidc config)', () => {
  assertEqual(simulateLockoutCheck(true, false, false), 'ok');
});

test('both enabled is always ok', () => {
  assertEqual(simulateLockoutCheck(true, true, true), 'ok');
  assertEqual(simulateLockoutCheck(true, true, false), 'ok');
});

// ── User provisioning ────────────────────────────────────────────────────────

console.log('\nfindOrProvisionUser');

function countUsers(username) {
  return getDb().prepare('SELECT COUNT(*) AS n FROM users WHERE username = ?').get(username).n;
}

test('auto-provision true creates local OIDC user', async () => {
  clearOidcEnv();
  resetOidcSettings();
  setSetting('oidc_admin_group', 'bt-admins');
  const user = await oidcService.findOrProvisionUser({
    sub: 'sub-create-1',
    preferred_username: 'sso-user',
    email: 'sso@example.com',
    email_verified: true,
    name: 'SSO User',
    groups: ['users'],
  }, getOidcConfig() || {
    adminGroup: 'bt-admins',
    defaultRole: 'user',
    autoProvision: true,
  });
  assertEqual(user.auth_provider, 'oidc');
  assertEqual(user.external_subject, 'sub-create-1');
  assertEqual(user.username, 'sso-user');
  assertEqual(user.role, 'user');
  assertEqual(user.first_login, 0);
  assertEqual(user.must_change_password, 0);
  assertEqual(user.password_hash, '');
});

test('auto-provision false rejects unknown OIDC user', async () => {
  clearOidcEnv();
  resetOidcSettings();
  setSetting('oidc_auto_provision', 'false');
  let rejected = false;
  try {
    await oidcService.findOrProvisionUser({
      sub: 'sub-denied-1',
      preferred_username: 'denied-user',
    }, { adminGroup: null, defaultRole: 'user', autoProvision: false });
  } catch (err) {
    rejected = err.status === 403;
  }
  assert(rejected, 'unknown OIDC user should be rejected with 403');
});

test('existing OIDC user maps by provider and subject', async () => {
  clearOidcEnv();
  resetOidcSettings();
  const first = await oidcService.findOrProvisionUser({
    sub: 'sub-existing-1',
    preferred_username: 'existing-oidc',
  }, { adminGroup: null, defaultRole: 'user', autoProvision: true });
  const second = await oidcService.findOrProvisionUser({
    sub: 'sub-existing-1',
    preferred_username: 'different-name',
  }, { adminGroup: null, defaultRole: 'user', autoProvision: true });
  assertEqual(second.id, first.id);
  assertEqual(countUsers('different-name'), 0);
});

test('verified email links existing local user', async () => {
  clearOidcEnv();
  resetOidcSettings();
  getDb().prepare(`
    INSERT INTO users (username, password_hash, role, auth_provider, email)
    VALUES ('local-link', 'hash', 'user', 'local', 'link@example.com')
  `).run();
  const linked = await oidcService.findOrProvisionUser({
    sub: 'sub-link-1',
    preferred_username: 'new-link-name',
    email: 'link@example.com',
    email_verified: true,
  }, { adminGroup: null, defaultRole: 'user', autoProvision: true });
  assertEqual(linked.username, 'local-link');
  assertEqual(linked.auth_provider, 'oidc');
  assertEqual(linked.external_subject, 'sub-link-1');
});

test('unverified email does not link existing local user', async () => {
  clearOidcEnv();
  resetOidcSettings();
  getDb().prepare(`
    INSERT INTO users (username, password_hash, role, auth_provider, email)
    VALUES ('local-unverified', 'hash', 'user', 'local', 'unverified@example.com')
  `).run();
  const created = await oidcService.findOrProvisionUser({
    sub: 'sub-unverified-1',
    preferred_username: 'unverified-created',
    email: 'unverified@example.com',
    email_verified: false,
  }, { adminGroup: null, defaultRole: 'user', autoProvision: true });
  assertEqual(created.username, 'unverified-created');
  const local = getDb().prepare("SELECT auth_provider FROM users WHERE username = 'local-unverified'").get();
  assertEqual(local.auth_provider, 'local');
});

test('admin role only granted by configured group', async () => {
  clearOidcEnv();
  resetOidcSettings();
  setSetting('oidc_admin_group', 'bt-admins');
  const admin = await oidcService.findOrProvisionUser({
    sub: 'sub-admin-1',
    preferred_username: 'admin-sso',
    groups: ['bt-admins'],
  }, { adminGroup: 'bt-admins', defaultRole: 'user', autoProvision: true });
  const user = await oidcService.findOrProvisionUser({
    sub: 'sub-user-1',
    preferred_username: 'plain-sso',
    groups: ['users'],
  }, { adminGroup: 'bt-admins', defaultRole: 'user', autoProvision: true });
  assertEqual(admin.role, 'admin');
  assertEqual(user.role, 'user');
});

test('OIDC-created user cannot local-password login', async () => {
  const { login } = require('../services/authService');
  clearOidcEnv();
  resetOidcSettings();
  await oidcService.findOrProvisionUser({
    sub: 'sub-no-local-1',
    preferred_username: 'oidc-no-local',
  }, { adminGroup: null, defaultRole: 'user', autoProvision: true });
  const result = await login('oidc-no-local', 'anything');
  assertEqual(result, null);
});

// ── Summary ───────────────────────────────────────────────────────────────────

(async () => {
  for (const item of tests) {
    await runTest(item);
  }

  console.log(`\n${'─'.repeat(50)}`);
  console.log(`  ${passed} passed, ${failed} failed`);
  closeDb();
  fs.rmSync(tempDir, { recursive: true, force: true });
  if (failed > 0) {
    console.error('\nSome tests failed.');
    process.exit(1);
  }
  console.log('  All OIDC smoke tests passed.\n');
})();
