import assert from 'node:assert/strict';
import test from 'node:test';

import { Common } from '../../backend/utils/common.js';

test('maskPasswords masks TOTP and SSO cookie secrets along with passwords', () => {
  const config = {
    secret2FA: 'JBSWY3DPEHPK3PXP',
    multiPassHashed: 'password-hash',
    SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', cookieValue: 'live-sso-cookie' },
    nodes: [{ index: 1, authentication: { lnApiPassword: 'eclair-pass', macaroonPath: '/macaroon/path' } }]
  };
  const masked = Common.maskPasswords(config);
  assert.equal(masked.secret2FA, '*'.repeat(20));
  assert.equal(masked.SSO.cookieValue, '*'.repeat(20));
  assert.equal(masked.multiPassHashed, '*'.repeat(20));
  assert.equal(masked.nodes[0].authentication.lnApiPassword, '*'.repeat(20));
  // Paths are configuration, not secrets — they must stay visible for the settings UI.
  assert.equal(masked.nodes[0].authentication.macaroonPath, '/macaroon/path');
  assert.equal(masked.SSO.rtlCookiePath, '/cookie-path');
});

test('removeSecureData strips the SSO cookie along with the other secrets', () => {
  const config = {
    rtlConfFilePath: '/conf',
    rtlPass: 'password-hash',
    multiPassHashed: 'password-hash',
    secret2FA: 'JBSWY3DPEHPK3PXP',
    SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '', cookieValue: 'live-sso-cookie' },
    nodes: [{ index: 1, authentication: { macaroonPath: '/macaroon/path', runeValue: 'rune', options: {} } }]
  };
  const cleaned = Common.removeSecureData(config);
  assert.equal(cleaned.rtlConfFilePath, undefined);
  assert.equal(cleaned.rtlPass, undefined);
  assert.equal(cleaned.multiPassHashed, undefined);
  assert.equal(cleaned.secret2FA, undefined);
  assert.equal(cleaned.SSO.cookieValue, undefined);
  // Non-secret SSO settings survive — the settings UI renders them.
  assert.equal(cleaned.SSO.rtlCookiePath, '/cookie-path');
  assert.equal(cleaned.nodes[0].authentication.macaroonPath, undefined);
});

test('removeSecureData does not mutate its input', () => {
  // cookieValue is runtime-only: if a caller ever passes the live appConfig, an in-place
  // delete would wipe SSO state with no way to restore it. The function must clone.
  const config = { rtlPass: 'password-hash', SSO: { cookieValue: 'live-sso-cookie' }, nodes: [] };
  Common.removeSecureData(config);
  assert.equal(config.rtlPass, 'password-hash');
  assert.equal(config.SSO.cookieValue, 'live-sso-cookie');
});

test('maskPasswords masks rtlPass and runeValue', () => {
  const config = {
    rtlPass: 'login-hash',
    nodes: [{ index: 1, authentication: { runeValue: 'cln-rune' } }]
  };
  const masked = Common.maskPasswords(config);
  assert.equal(masked.rtlPass, '*'.repeat(20));
  assert.equal(masked.nodes[0].authentication.runeValue, '*'.repeat(20));
});

test('maskPasswords tolerates null values and numeric keys without skipping secrets', () => {
  // Integer-like keys order first; the recursion must not clobber its own key list, and
  // typeof null === 'object' must not send it into Object.keys(null).
  const config = { '1': { nested: 'value' }, lnApiPassword: 'eclair-pass', nothing: null };
  const masked = Common.maskPasswords(config);
  assert.equal(masked.lnApiPassword, '*'.repeat(20));
  assert.equal(masked.nothing, null);
  assert.deepEqual(masked['1'], { nested: 'value' });
});

test('handleError does not echo the absolute file path to the caller', () => {
  // The path belongs in the server log, not in the API response.
  const err = Common.handleError({ code: 'ENOENT', path: '/secret/dir/RTL-Config.json' }, 'Test', 'Reading Config Error', { lnImplementation: 'LND', settings: {} });
  assert.equal(err.error.includes('/secret/dir'), false);
  assert.equal(err.message.includes('/secret/dir'), false);
});

test('handleError keeps the absolute path out of controller-wrapped errors too', () => {
  // RTLConf handlers pass { statusCode, message, error: errRes } wrappers; the response
  // must resolve to the caller's generic message, never the wrapped fs error's path.
  const err = Common.handleError(
    { statusCode: 500, message: 'Reading File Error', error: { code: 'ENOENT', path: '/secret/dir/x.bak' } },
    'Test', 'Reading File Error', { lnImplementation: 'LND', settings: {} }
  );
  assert.equal(err.error.includes('/secret/dir'), false);
  assert.equal(err.message.includes('/secret/dir'), false);
});

test('maskPasswords masks every value under a headers key', () => {
  // Header values are always credential carriers here (macaroon, rune, basic auth), and
  // key-substring matching cannot catch them without also hiding *Path fields.
  const config = {
    authentication: {
      macaroonPath: '/visible/path',
      options: { headers: { 'Grpc-Metadata-macaroon': 'deadbeef', rune: 'cln-rune', authorization: 'Basic xyz' } }
    }
  };
  const masked = Common.maskPasswords(config);
  assert.equal(masked.authentication.options.headers['Grpc-Metadata-macaroon'], '*'.repeat(20));
  assert.equal(masked.authentication.options.headers.rune, '*'.repeat(20));
  assert.equal(masked.authentication.options.headers.authorization, '*'.repeat(20));
  assert.equal(masked.authentication.macaroonPath, '/visible/path');
});

const seedAppConfig = () => {
  Common.appConfig = {
    defaultNodeIndex: 0,
    selectedNodeIndex: 0,
    rtlConfFilePath: '/conf',
    dbDirectoryPath: '/db',
    rtlPass: 'server-hash',
    allowPasswordUpdate: true,
    enable2FA: true,
    secret2FA: 'server-seed',
    disableAuth: false,
    SSO: { rtlSSO: 0, rtlCookiePath: '/server-cookie', logoutRedirectLink: 'https://server-logout', cookieValue: 'server-cookie' },
    nodes: []
  };
  Common.selectedNode = null;
  Common.nodes = [];
};

test('addSecureData pins disableAuth and the SSO object to server-held values', () => {
  // The settings API must not be able to flip the authentication mode or move SSO fields;
  // client-supplied values for these are deployment-level switches, not settings.
  seedAppConfig();
  const config = Common.addSecureData({
    disableAuth: true,
    SSO: { rtlSSO: 1, rtlCookiePath: '/client-path', logoutRedirectLink: 'https://client', cookieValue: 'client-cookie' },
    secret2FA: 'client-seed',
    nodes: []
  });
  assert.equal(config.disableAuth, false);
  assert.deepEqual(config.SSO, { rtlSSO: 0, rtlCookiePath: '/server-cookie', logoutRedirectLink: 'https://server-logout', cookieValue: 'server-cookie' });
  // An explicit non-empty seed is the settings UI's enable flow and is honored.
  assert.equal(config.secret2FA, 'client-seed');
  assert.equal(config.enable2FA, true);
});

test('addSecureData restores an omitted TOTP seed and derives enable2FA from the seed', () => {
  seedAppConfig();
  const config = Common.addSecureData({ nodes: [] });
  assert.equal(config.secret2FA, 'server-seed');
  assert.equal(config.enable2FA, true);
});

test('addSecureData treats an empty seed with 2FA claimed on as an omission', () => {
  // The pre-login config response shape carries secret2FA: ''; echoing it must not wipe
  // the seed while enable2FA stays on.
  seedAppConfig();
  const config = Common.addSecureData({ secret2FA: '', enable2FA: true, nodes: [] });
  assert.equal(config.secret2FA, 'server-seed');
  assert.equal(config.enable2FA, true);
});

test('addSecureData honors an explicit seed wipe only when 2FA is disabled', () => {
  // The settings UI's disable flow sends secret2FA: '' together with enable2FA: false.
  seedAppConfig();
  const config = Common.addSecureData({ secret2FA: '', enable2FA: false, nodes: [] });
  assert.equal(config.secret2FA, '');
  assert.equal(config.enable2FA, false);
});

test('addSecureData does not pin an undefined multiPassHashed over the persisted one', () => {
  // First-boot state of a default install: the file already holds multiPassHashed (the
  // boot converted it), but the in-memory appConfig still holds plaintext multiPass and
  // no hash. Pinning undefined here would erase the only password from the file on save
  // and brick the next boot.
  seedAppConfig();
  Common.appConfig.multiPassHashed = undefined;
  Common.appConfig.multiPass = 'password';
  const config = Common.addSecureData({ nodes: [] });
  assert.equal(Object.prototype.hasOwnProperty.call(config, 'multiPassHashed'), false);
  assert.equal(config.multiPass, 'password');
});

test('addSecureData pins multiPassHashed when the server holds one', () => {
  seedAppConfig();
  Common.appConfig.multiPassHashed = 'server-hash-value';
  const config = Common.addSecureData({ multiPassHashed: 'client-value', nodes: [] });
  assert.equal(config.multiPassHashed, 'server-hash-value');
});

test('addSecureData pins allowPasswordUpdate and dbDirectoryPath to server-held values', () => {
  // allowPasswordUpdate is false precisely when the password is environment-managed, and
  // dbDirectoryPath redirects the runtime database — neither is writable from the UI.
  seedAppConfig();
  Common.appConfig.allowPasswordUpdate = false;
  Common.appConfig.dbDirectoryPath = '/server-db';
  const config = Common.addSecureData({ allowPasswordUpdate: true, dbDirectoryPath: '/client-db', nodes: [] });
  assert.equal(config.allowPasswordUpdate, false);
  assert.equal(config.dbDirectoryPath, '/server-db');
});

test('maskPasswords masks bitcoind rpcauth', () => {
  const config = { rpcauth: 'user:salt$hmac', rpcuser: 'user', rpcpassword: 'pass' };
  const masked = Common.maskPasswords(config);
  assert.equal(masked.rpcauth, '*'.repeat(20));
  assert.equal(masked.rpcuser, '*'.repeat(20));
  assert.equal(masked.rpcpassword, '*'.repeat(20));
});

test('maskPasswords does not mutate its input', () => {
  // Masking a live object must not blank the credentials LN requests authenticate with.
  const config = { authentication: { options: { headers: { 'Grpc-Metadata-macaroon': 'deadbeef' } } } };
  Common.maskPasswords(config);
  assert.equal(config.authentication.options.headers['Grpc-Metadata-macaroon'], 'deadbeef');
});
