import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { default as Logger, Metadata, NULL_LOGGER } from '../../src/resources/logger.js';

describe('Logger', () => {
  it('metadata', () => {
    let metadata: Metadata = { correlation_id: 'test' };
    const logger = new Logger(metadata);

    // Changing initial object should not affect the logger
    metadata.correlation_id = 'changed';
    assert.deepEqual(logger.getMetadata(), { correlation_id: 'test' });

    // Changing returned metadata object should not affect the logger
    metadata = logger.getMetadata();
    metadata.correlation_id = 'changed';
    assert.deepEqual(logger.getMetadata(), { correlation_id: 'test' });

    // Can overwrite existing metadata
    logger.setMetadata('correlation_id', 'changed');
    assert.deepEqual(logger.getMetadata(), { correlation_id: 'changed' });

    // Can merge metadata
    logger.decorate({ other: 'metadata' });
    assert.deepEqual(logger.getMetadata(), { correlation_id: 'changed', other: 'metadata' });

    // Can overwrite existing metadata
    logger.decorate({ other: 'overwrite' });
    assert.deepEqual(logger.getMetadata(), { correlation_id: 'changed', other: 'overwrite' });

    // Can clear metadata
    logger.clearMetadata();
    assert.deepEqual(logger.getMetadata(), {});
  });

  it('respect logging levels', testContext => {
    const metadata = { correlation_id: '123456789' };
    const logger = new Logger(metadata);

    const logSpy = testContext.mock.method(global.console, 'log', () => {});
    assert.strictEqual(logSpy.mock.calls.length, 0);
    logger.log('test');
    assert.strictEqual(logSpy.mock.calls.length, 1);
    let actual = JSON.parse(logSpy.mock.calls[0]?.arguments[0]);
    assert.ok(actual['date']);
    assert.equal(actual['correlation_id'], '123456789');
    assert.equal(actual['message'], 'test');
    assert.equal(actual['status'], 'log');

    const errorSpy = testContext.mock.method(global.console, 'error', () => {});
    assert.strictEqual(errorSpy.mock.calls.length, 0);
    logger.error('test');
    assert.strictEqual(errorSpy.mock.calls.length, 1);
    actual = JSON.parse(errorSpy.mock.calls[0]?.arguments[0]);
    assert.ok(actual['date']);
    assert.equal(actual['correlation_id'], '123456789');
    assert.equal(actual['message'], 'test');
    assert.equal(actual['status'], 'error');

    const warnSpy = testContext.mock.method(global.console, 'warn', () => {});
    assert.strictEqual(warnSpy.mock.calls.length, 0);
    logger.warn('test');
    assert.strictEqual(warnSpy.mock.calls.length, 1);
    actual = JSON.parse(warnSpy.mock.calls[0]?.arguments[0]);
    assert.ok(actual['date']);
    assert.equal(actual['correlation_id'], '123456789');
    assert.equal(actual['message'], 'test');
    assert.equal(actual['status'], 'warn');

    const infoSpy = testContext.mock.method(global.console, 'info', () => {});
    assert.strictEqual(infoSpy.mock.calls.length, 0);
    logger.info('test');
    assert.strictEqual(infoSpy.mock.calls.length, 1);
    actual = JSON.parse(infoSpy.mock.calls[0]?.arguments[0]);
    assert.ok(actual['date']);
    assert.equal(actual['correlation_id'], '123456789');
    assert.equal(actual['message'], 'test');
    assert.equal(actual['status'], 'info');

    const debugSpy = testContext.mock.method(global.console, 'debug', () => {});
    assert.strictEqual(debugSpy.mock.calls.length, 0);
    logger.debug('test');
    assert.strictEqual(debugSpy.mock.calls.length, 1);
    actual = JSON.parse(debugSpy.mock.calls[0]?.arguments[0]);
    assert.ok(actual['date']);
    assert.equal(actual['correlation_id'], '123456789');
    assert.equal(actual['message'], 'test');
    assert.equal(actual['status'], 'debug');
  });

  it('merges message payload with metadata', testContext => {
    const logSpy = testContext.mock.method(global.console, 'log', () => {});
    assert.strictEqual(logSpy.mock.calls.length, 0);

    const metadata = { correlation_id: '123456789', http: { method: 'GET' } };
    const logger = new Logger(metadata);
    logger.log('test', { error: { code: '200', message: 'Page Not Found' } });
    assert.strictEqual(logSpy.mock.calls.length, 1);

    const actual = JSON.parse(logSpy.mock.calls[0]?.arguments[0]);

    assert.ok(actual['date']);
    assert.equal(actual['correlation_id'], '123456789');
    assert.equal(actual['message'], 'test');
    assert.equal(actual['status'], 'log');
    assert.deepEqual(actual['http'], { method: 'GET' });
    assert.deepEqual(actual['error'], { code: '200', message: 'Page Not Found' });
  });

  it('overwrites conflicting metadata keys (1st level) with message payload', testContext => {
    const logSpy = testContext.mock.method(global.console, 'log', () => {});
    assert.strictEqual(logSpy.mock.calls.length, 0);

    const metadata = { correlation_id: '123456789', http: { method: 'GET' } };
    const logger = new Logger(metadata);
    logger.log('test', { http: { status_code: 200 } });
    assert.strictEqual(logSpy.mock.calls.length, 1);

    const actual = JSON.parse(logSpy.mock.calls[0]?.arguments[0]);

    assert.ok(actual['date']);
    assert.equal(actual['correlation_id'], '123456789');
    assert.equal(actual['message'], 'test');
    assert.equal(actual['status'], 'log');
    assert.deepEqual(actual['http'], { status_code: 200 });
  });

  it('snakify keys of Message and Metadata', testContext => {
    const logSpy = testContext.mock.method(global.console, 'log', () => {});
    assert.strictEqual(logSpy.mock.calls.length, 0);

    const metadata = {
      correlationId: '123456789',
      http: { method: 'GET', statusCode: 200 },
      links: [
        { id: 1, organizationId: 'a' },
        { id: 2, organizationId: 'b' },
      ],
    };
    const logger = new Logger(metadata);
    logger.log('test', { errorContext: { errorCode: 200, errorMessage: 'Page Not Found' } });
    assert.strictEqual(logSpy.mock.calls.length, 1);

    const actual = JSON.parse(logSpy.mock.calls[0]?.arguments[0]);

    assert.ok(actual['date']);
    assert.equal(actual['correlation_id'], '123456789');
    assert.equal(actual['message'], 'test');
    assert.equal(actual['status'], 'log');
    assert.deepEqual(actual['http'], { method: 'GET', status_code: 200 });
    assert.deepEqual(actual['error_context'], { error_code: 200, error_message: 'Page Not Found' });
    assert.deepEqual(actual['links'], [
      { id: 1, organization_id: 'a' },
      { id: 2, organization_id: 'b' },
    ]);
  });

  it('prunes sensitive Metadata', testContext => {
    const logSpy = testContext.mock.method(global.console, 'log', () => {});
    assert.strictEqual(logSpy.mock.calls.length, 0);

    const metadata = {
      correlationId: '123456789',
      http: { method: 'GET', statusCode: 200, jwt: 'deepSecret' },
      user: { contact: { email: 'deep_deep_deep@email.address', firstName: 'should be snakify then hidden' } },
      access_token: 'secret',
      items: [
        { id: 1, organizationId: 'a' },
        { id: 2, organizationId: 'b' },
      ],
    };
    const logger = new Logger(metadata);
    logger.log('test', { errorContext: { errorCode: 200, errorMessage: 'Page Not Found' } });
    assert.strictEqual(logSpy.mock.calls.length, 1);

    const actual = JSON.parse(logSpy.mock.calls[0]?.arguments[0]);
    assert.ok(actual['date']);
    assert.equal(actual['correlation_id'], '123456789');
    assert.equal(actual['message'], 'test');
    assert.equal(actual['status'], 'log');
    assert.equal(actual['has_sensitive_attribute'], true);
    assert.equal(actual['access_token'], '[REDACTED]');
    assert.deepEqual(actual['http'], { method: 'GET', status_code: 200, jwt: '[REDACTED]' });
    assert.deepEqual(actual['user']['contact'], { email: '[REDACTED]', first_name: '[REDACTED]' });
    assert.deepEqual(actual['items'], [
      { id: 1, organization_id: 'a' },
      { id: 2, organization_id: 'b' },
    ]);
  });

  it(`NULL_LOGGER should not log`, testContext => {
    const logger = NULL_LOGGER;

    const logSpy = testContext.mock.method(global.console, 'log', () => {});
    const errorSpy = testContext.mock.method(global.console, 'error', () => {});
    const warnSpy = testContext.mock.method(global.console, 'warn', () => {});
    const infoSpy = testContext.mock.method(global.console, 'info', () => {});
    const debugSpy = testContext.mock.method(global.console, 'debug', () => {});

    logger.log('test');
    logger.info('test');
    logger.warn('test');
    logger.error('test');
    logger.debug('test');

    assert.strictEqual(logSpy.mock.calls.length, 0);
    assert.strictEqual(errorSpy.mock.calls.length, 0);
    assert.strictEqual(warnSpy.mock.calls.length, 0);
    assert.strictEqual(infoSpy.mock.calls.length, 0);
    assert.strictEqual(debugSpy.mock.calls.length, 0);
  });

  it('colorizes logs by status codes over log levels', () => {
    const originalEnv = process.env.NODE_ENV;
    const originalIsTTY = process.stdout.isTTY;

    try {
      process.env.NODE_ENV = 'development';
      Object.defineProperty(process.stdout, 'isTTY', {
        value: true,
        configurable: true,
      });

      // 4xx status with warn level, should be red
      const metadataWith400 = {
        http: { status_code: 400 },
      };
      const redResult = Logger['colorize']('Some error', metadataWith400, 'warn' as any);

      // warn level without status code, should be yellow
      const metadataNoStatus = {};
      const yellowResult = Logger['colorize']('Some warning', metadataNoStatus, 'warn' as any);

      // Results should be different colors
      assert.notEqual(redResult, yellowResult);
      assert.ok(redResult.includes('\x1b[31m')); // Red color code
      assert.ok(yellowResult.includes('\x1b[33m')); // Yellow color code
    } finally {
      // Restore original values
      process.env.NODE_ENV = originalEnv;
      Object.defineProperty(process.stdout, 'isTTY', {
        value: originalIsTTY,
        configurable: true,
      });
    }
  });

  it('colorizes logs with different status code ranges', () => {
    const originalEnv = process.env.NODE_ENV;
    const originalIsTTY = process.stdout.isTTY;

    try {
      process.env.NODE_ENV = 'development';
      Object.defineProperty(process.stdout, 'isTTY', {
        value: true,
        configurable: true,
      });

      // 2xx status codes should be green
      const successMetadata = {
        http: { status_code: 200 },
      };
      const greenResult = Logger['colorize']('Success', successMetadata, 'info' as any);
      assert.ok(greenResult.includes('\x1b[32m')); // Green color code

      // 3xx status codes should be yellow
      const redirectMetadata = {
        http: { status_code: 301 },
      };
      const yellowResult = Logger['colorize']('Redirect', redirectMetadata, 'info' as any);
      assert.ok(yellowResult.includes('\x1b[33m')); // Yellow color code

      // 4xx status codes should be red
      const clientErrorMetadata = {
        http: { status_code: 404 },
      };
      const redResult = Logger['colorize']('Not Found', clientErrorMetadata, 'warn' as any);
      assert.ok(redResult.includes('\x1b[31m')); // Red color code

      // 5xx status codes should be red
      const serverErrorMetadata = {
        http: { status_code: 500 },
      };
      const redResult2 = Logger['colorize']('Server Error', serverErrorMetadata, 'error' as any);
      assert.ok(redResult2.includes('\x1b[31m')); // Red color code
    } finally {
      process.env.NODE_ENV = originalEnv;
      Object.defineProperty(process.stdout, 'isTTY', {
        value: originalIsTTY,
        configurable: true,
      });
    }
  });

  it('only logs date for metadata of successful requests in development', testContext => {
    const originalEnv = process.env.NODE_ENV;

    try {
      process.env.NODE_ENV = 'development';

      const infoSpy = testContext.mock.method(global.console, 'info', () => {});
      const metadata = {
        correlation_id: '123456789',
        http: { method: 'GET', status_code: 200 },
        user_id: 'user123',
        request_id: 'req456',
      };

      const logger = new Logger(metadata);

      logger.info('Test message without error');
      assert.strictEqual(infoSpy.mock.calls.length, 1);

      const loggedOutput = infoSpy.mock.calls[0]?.arguments[0];
      assert.ok(loggedOutput.includes('Test message without error'));

      // Should only contain date in metadata JSON
      const metadataMatch = loggedOutput.match(/({.*})/);
      assert.ok(metadataMatch);
      const parsedMetadata = JSON.parse(metadataMatch[1]);
      assert.ok(parsedMetadata.date);
      assert.strictEqual(Object.keys(parsedMetadata).length, 1); // What matters: Only the date
    } finally {
      process.env.NODE_ENV = originalEnv;
    }
  });

  it('logs date & error stack for metadata of failed requests in development', testContext => {
    const originalEnv = process.env.NODE_ENV;

    try {
      process.env.NODE_ENV = 'development';

      const metadata = {
        correlation_id: '123456789',
        http: { method: 'GET', status_code: 200 },
        user_id: 'user123',
        request_id: 'req456',
      };

      const logger = new Logger(metadata);

      // Test with error metadata
      const errorSpy = testContext.mock.method(global.console, 'error', () => {});
      logger.error('Test message with error', { error: { code: 500, message: 'Internal Server Error' } });
      assert.strictEqual(errorSpy.mock.calls.length, 1);

      const errorLoggedOutput = errorSpy.mock.calls[0]?.arguments[0];
      assert.ok(errorLoggedOutput.includes('Test message with error'));

      // Should contain both date and error in metadata JSON
      const errorMetadataMatch = errorLoggedOutput.match(/({.*})/s);
      assert.ok(errorMetadataMatch);
      const parsedErrorMetadata = JSON.parse(errorMetadataMatch[1]);
      assert.ok(parsedErrorMetadata.date);
      assert.ok(parsedErrorMetadata.error);
      assert.deepEqual(parsedErrorMetadata.error, { code: 500, message: 'Internal Server Error' });
      assert.strictEqual(Object.keys(parsedErrorMetadata).length, 2); // What matters: Date and error only
    } finally {
      process.env.NODE_ENV = originalEnv;
    }
  });
});
