import { redactSecrets, redactSecretsInText, stringifyError, truncate } from '../harness/redact';

describe('redactSecrets', () => {
	it('redacts values under secret-shaped keys', () => {
		const input = {
			username: 'alice',
			password: 'hunter2',
			apiKey: 'sk-abc',
			api_key: 'sk-def',
			'X-Api-Key': 'sk-ghi',
			authorization: 'Bearer xyz',
			refreshToken: 'rt-1',
			cookie: 'sid=1',
			privateKey: '----BEGIN----',
			sessionId: 's-1',
			credentials: { value: 'opaque' },
		};

		expect(redactSecrets(input)).toEqual({
			username: 'alice',
			password: '[REDACTED]',
			apiKey: '[REDACTED]',
			api_key: '[REDACTED]',
			'X-Api-Key': '[REDACTED]',
			authorization: '[REDACTED]',
			refreshToken: '[REDACTED]',
			cookie: '[REDACTED]',
			privateKey: '[REDACTED]',
			sessionId: '[REDACTED]',
			credentials: '[REDACTED]',
		});
	});

	it('walks nested objects and arrays', () => {
		const input = {
			outer: {
				inner: { token: 't', name: 'ok' },
				list: [{ password: 'p', kept: 'k' }],
			},
		};

		expect(redactSecrets(input)).toEqual({
			outer: {
				inner: { token: '[REDACTED]', name: 'ok' },
				list: [{ password: '[REDACTED]', kept: 'k' }],
			},
		});
	});

	it('passes primitives, null, and undefined through unchanged', () => {
		expect(redactSecrets('plain')).toBe('plain');
		expect(redactSecrets(42)).toBe(42);
		expect(redactSecrets(true)).toBe(true);
		expect(redactSecrets(null)).toBeNull();
		expect(redactSecrets(undefined)).toBeUndefined();
	});

	it('does not mutate the original object', () => {
		const original = { token: 'real-token' };
		redactSecrets(original);
		expect(original.token).toBe('real-token');
	});

	it('caps recursion depth so deeply nested input cannot blow the stack', () => {
		let nested: unknown = { token: 'leaf' };
		for (let i = 0; i < 12; i += 1) {
			nested = { wrap: nested };
		}
		expect(() => redactSecrets(nested)).not.toThrow();
	});

	it('leaves class instances untouched (only redacts plain objects)', () => {
		class WithSecret {
			constructor(public token: string) {}
		}
		const instance = new WithSecret('keep-me');
		expect(redactSecrets(instance)).toBe(instance);
	});
});

describe('redactSecretsInText', () => {
	it('masks an Authorization credential while keeping the scheme and message', () => {
		const out = redactSecretsInText('Request failed: Authorization: Bearer sk-abc.def123 (401)');
		expect(out).not.toContain('sk-abc.def123');
		expect(out).toContain('Authorization: Bearer [REDACTED]');
		expect(out).toContain('Request failed:');
		expect(out).toContain('(401)');
	});

	it('masks a standalone Bearer/Basic credential', () => {
		expect(redactSecretsInText('header was Bearer eyJ0eXAiOiJ.payload')).toBe(
			'header was Bearer [REDACTED]',
		);
		expect(redactSecretsInText('got Basic dXNlcjpwYXNz back')).toBe('got Basic [REDACTED] back');
	});

	it('masks secret-shaped key/value pairs in query strings and JSON', () => {
		expect(redactSecretsInText('GET https://api.x.com/v1?api_key=secret123&page=2')).toBe(
			'GET https://api.x.com/v1?api_key=[REDACTED]&page=2',
		);
		expect(redactSecretsInText('body {"token":"abc","ok":true}')).toBe(
			'body {"token":"[REDACTED]","ok":true}',
		);
		expect(redactSecretsInText('password: hunter2')).toBe('password: [REDACTED]');
	});

	it('leaves secret words used as prose untouched (no separator → no match)', () => {
		expect(redactSecretsInText('Invalid token format in the request')).toBe(
			'Invalid token format in the request',
		);
		expect(redactSecretsInText('the secret sauce was missing')).toBe(
			'the secret sauce was missing',
		);
	});
});

describe('truncate', () => {
	it('passes short values through after redaction', () => {
		expect(truncate({ name: 'a', token: 't' }, 200)).toEqual({ name: 'a', token: '[REDACTED]' });
	});

	it('returns the truncated stringified form when over the limit', () => {
		const big = { msg: 'a'.repeat(500) };
		const out = truncate(big, 50);
		expect(typeof out).toBe('string');
		expect((out as string).endsWith('... [truncated]')).toBe(true);
	});

	it('returns "<unserializable>" when JSON.stringify throws on circular refs', () => {
		const circular: Record<string, unknown> = {};
		circular.self = circular;
		expect(truncate(circular, 200)).toBe('<unserializable>');
	});

	it('returns "<unserializable>" when JSON.stringify returns undefined', () => {
		const fn = (): void => {};
		expect(truncate(fn, 200)).toBe('<unserializable>');
	});
});

describe('stringifyError', () => {
	it('returns string errors unchanged when within limit', () => {
		expect(stringifyError('boom', 100)).toBe('boom');
	});

	it('truncates long string errors', () => {
		const long = 'x'.repeat(50);
		expect(stringifyError(long, 10)).toBe('xxxxxxxxxx');
	});

	it('JSON-stringifies object errors and redacts secrets', () => {
		const out = stringifyError({ message: 'fail', token: 'leaked' }, 200);
		expect(out).toContain('"message":"fail"');
		expect(out).toContain('[REDACTED]');
		expect(out).not.toContain('leaked');
	});

	it('falls back to String() when JSON.stringify returns undefined', () => {
		const fn = (): void => {};
		expect(stringifyError(fn, 200)).toBe(String(fn));
	});

	it('falls back to String() when JSON.stringify throws on circular refs', () => {
		const circular: Record<string, unknown> = { name: 'cycle' };
		circular.self = circular;
		expect(() => stringifyError(circular, 200)).not.toThrow();
		expect(typeof stringifyError(circular, 200)).toBe('string');
	});

	it('truncates serialized object errors past max length', () => {
		const big = { msg: 'a'.repeat(500) };
		const out = stringifyError(big, 50);
		expect(out.length).toBe(50);
	});
});
