/**
 * Tests for timeout utilities
 */

import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
import {
  DEFAULT_TIMEOUTS,
  TimeoutError,
  withTimeout,
  withTimeoutAndAbort,
  withRetry,
  isTimeoutError
} from './timeout.js';

// Suppress unhandled rejections from fake timer artifacts during testing
// These are cosmetic - tests properly catch and validate all expected rejections
let unhandledRejectionHandler: ((reason: unknown, promise: Promise<unknown>) => void) | undefined;

beforeAll(() => {
  unhandledRejectionHandler = (reason: unknown) => {
    // Suppress TimeoutError rejections from fake timer artifacts
    if (reason instanceof TimeoutError) {
      return;
    }
    // Suppress "Always fails" errors from retry tests with fake timers
    if (reason instanceof Error && reason.message === 'Always fails') {
      return;
    }
    // Re-throw other unhandled rejections
    throw reason;
  };
  process.on('unhandledRejection', unhandledRejectionHandler);
});

afterAll(() => {
  if (unhandledRejectionHandler) {
    process.off('unhandledRejection', unhandledRejectionHandler);
  }
});

describe('DEFAULT_TIMEOUTS', () => {
  it('should define standard EP API request timeout', () => {
    expect(DEFAULT_TIMEOUTS.EP_API_REQUEST_MS).toBe(60_000);
  });

  it('should define extended timeout for known slow feed endpoints', () => {
    expect(DEFAULT_TIMEOUTS.EP_FEED_SLOW_REQUEST_MS).toBe(120_000);
  });

  it('should have slow feed timeout greater than standard timeout', () => {
    expect(DEFAULT_TIMEOUTS.EP_FEED_SLOW_REQUEST_MS).toBeGreaterThan(DEFAULT_TIMEOUTS.EP_API_REQUEST_MS);
  });

  it('should define health check timeout', () => {
    expect(DEFAULT_TIMEOUTS.HEALTH_CHECK_MS).toBe(3_000);
  });

  it('should define retry delay', () => {
    expect(DEFAULT_TIMEOUTS.RETRY_DELAY_MS).toBe(1_000);
  });
});

describe('TimeoutError', () => {
  it('should create a timeout error with message', () => {
    const error = new TimeoutError('Operation timed out');
    
    expect(error).toBeInstanceOf(Error);
    expect(error).toBeInstanceOf(TimeoutError);
    expect(error.message).toBe('Operation timed out');
    expect(error.name).toBe('TimeoutError');
  });
  
  it('should include timeout duration', () => {
    const error = new TimeoutError('Timed out', 5000);
    
    expect(error.timeoutMs).toBe(5000);
  });
  
  it('should have stack trace', () => {
    const error = new TimeoutError('Test');
    
    expect(error.stack).toBeDefined();
  });
});

describe('withTimeout', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
  
  afterEach(() => {
    // Clear all pending timers to prevent unhandled rejections
    vi.clearAllTimers();
    vi.useRealTimers();
  });
  
  it('should resolve if promise completes before timeout', async () => {
    const promise = Promise.resolve('success');
    
    const resultPromise = withTimeout(promise, 1000);
    await vi.advanceTimersByTimeAsync(500);
    
    await expect(resultPromise).resolves.toBe('success');
  });
  
  it('should reject with TimeoutError if promise exceeds timeout', async () => {
    const promise = new Promise((resolve) => {
      setTimeout(() => resolve('success'), 2000);
    });
    
    const resultPromise = withTimeout(promise, 1000);
    await vi.advanceTimersByTimeAsync(1001);
    
    // Await the promise rejection to handle it
    try {
      await resultPromise;
      expect.fail('Should have thrown TimeoutError');
    } catch (error) {
      expect(error).toBeInstanceOf(TimeoutError);
      expect(error).toHaveProperty('message', 'Operation timed out after 1000ms');
    }
    
    // Advance remaining time to clear inner timeout
    await vi.advanceTimersByTimeAsync(1000);
  });
  
  it('should use custom error message', async () => {
    const promise = new Promise((resolve) => {
      setTimeout(() => resolve('success'), 2000);
    });
    
    const resultPromise = withTimeout(
      promise,
      1000,
      'Custom timeout message'
    );
    await vi.advanceTimersByTimeAsync(1001);
    
    // Await the promise rejection to handle it
    try {
      await resultPromise;
      expect.fail('Should have thrown TimeoutError');
    } catch (error) {
      expect(error).toHaveProperty('message', 'Custom timeout message');
    }
    
    // Advance remaining time to clear inner timeout
    await vi.advanceTimersByTimeAsync(1000);
  });
  
  it('should include timeout duration in error', async () => {
    const promise = new Promise((resolve) => {
      setTimeout(() => resolve('success'), 2000);
    });
    
    const resultPromise = withTimeout(promise, 1500);
    await vi.advanceTimersByTimeAsync(1501);
    
    try {
      await resultPromise;
      expect.fail('Should have thrown TimeoutError');
    } catch (error) {
      expect(error).toBeInstanceOf(TimeoutError);
      if (error instanceof TimeoutError) {
        expect(error.timeoutMs).toBe(1500);
      }
    }
    
    // Advance remaining time to clear inner timeout
    await vi.advanceTimersByTimeAsync(500);
  });
  
  it('should propagate promise rejection', async () => {
    const promise = Promise.reject(new Error('Original error'));
    
    const resultPromise = withTimeout(promise, 1000);
    
    await expect(resultPromise).rejects.toThrow('Original error');
  });
});

describe('withTimeoutAndAbort', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
  
  afterEach(() => {
    // Clear all pending timers to prevent unhandled rejections
    vi.clearAllTimers();
    vi.useRealTimers();
  });
  
  it('should resolve if operation completes before timeout', async () => {
    const operation = vi.fn(async (signal: AbortSignal) => {
      expect(signal.aborted).toBe(false);
      return 'success';
    });
    
    const resultPromise = withTimeoutAndAbort(operation, 1000);
    
    const result = await resultPromise;
    
    expect(result).toBe('success');
    expect(operation).toHaveBeenCalled();
  });
  
  it('should abort the signal when timeout fires', async () => {
    let signalAborted = false;
    const operation = async (signal: AbortSignal) => {
      // Simulate long-running operation
      return new Promise<string>((resolve) => {
        setTimeout(() => resolve('late'), 2000);
        signal.addEventListener('abort', () => {
          signalAborted = true;
        });
      });
    };
    
    const resultPromise = withTimeoutAndAbort(operation, 1000);
    
    // Advance time past timeout
    vi.advanceTimersByTime(1001);
    
    await expect(resultPromise).rejects.toThrow(TimeoutError);
    await vi.runAllTimersAsync();
    
    expect(signalAborted).toBe(true);
  });
  
  it('should not abort signal when operation completes successfully', async () => {
    let signalAborted = false;
    const operation = async (signal: AbortSignal) => {
      signal.addEventListener('abort', () => {
        signalAborted = true;
      });
      return 'success';
    };
    
    const result = await withTimeoutAndAbort(operation, 1000);
    
    expect(result).toBe('success');
    expect(signalAborted).toBe(false);
  });
  
  it('should use custom error message', async () => {
    const operation = async (_signal: AbortSignal) => {
      return new Promise<string>((resolve) => {
        setTimeout(() => resolve('late'), 2000);
      });
    };
    
    const resultPromise = withTimeoutAndAbort(operation, 500, 'Custom timeout message');
    
    vi.advanceTimersByTime(501);
    
    await expect(resultPromise).rejects.toThrow('Custom timeout message');
  });
  
  it('should propagate operation errors', async () => {
    const operation = async (_signal: AbortSignal) => {
      throw new Error('Operation failed');
    };
    
    const resultPromise = withTimeoutAndAbort(operation, 1000);
    
    await expect(resultPromise).rejects.toThrow('Operation failed');
  });
  
  it('should clear timeout when operation completes', async () => {
    const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
    
    const operation = async (_signal: AbortSignal) => 'success';
    
    await withTimeoutAndAbort(operation, 1000);
    
    expect(clearTimeoutSpy).toHaveBeenCalled();
    
    clearTimeoutSpy.mockRestore();
  });
});

describe('withRetry', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
  
  afterEach(() => {
    // Clear all pending timers to prevent unhandled rejections
    vi.clearAllTimers();
    vi.useRealTimers();
  });
  
  it('should succeed on first attempt', async () => {
    const fn = vi.fn().mockResolvedValue('success');
    
    const result = await withRetry(fn, {
      maxRetries: 3,
      timeoutMs: 1000
    });
    
    expect(result).toBe('success');
    expect(fn).toHaveBeenCalledTimes(1);
  });
  
  it('should retry on failure', async () => {
    // Mock Math.random to return 0 so jitter is 0 and timing is deterministic
    vi.spyOn(Math, 'random').mockReturnValue(0);
    try {
      const fn = vi.fn()
        .mockRejectedValueOnce(new Error('Attempt 1 failed'))
        .mockRejectedValueOnce(new Error('Attempt 2 failed'))
        .mockResolvedValue('success');
      
      const resultPromise = withRetry(fn, {
        maxRetries: 3,
        timeoutMs: 1000,
        retryDelayMs: 100
      });
      
      // Advance timers for retries (jitter = 0 with Math.random mock)
      await vi.advanceTimersByTimeAsync(100); // First retry delay
      await vi.advanceTimersByTimeAsync(200); // Second retry delay (exponential)
      
      const result = await resultPromise;
      
      expect(result).toBe('success');
      expect(fn).toHaveBeenCalledTimes(3);
    } finally {
      vi.restoreAllMocks();
    }
  });
  
  it('should throw after max retries exhausted', async () => {
    // Mock Math.random to return 0 so jitter is 0 and timing is deterministic
    vi.spyOn(Math, 'random').mockReturnValue(0);
    try {
      const fn = vi.fn().mockRejectedValue(new Error('Always fails'));
      
      const resultPromise = withRetry(fn, {
        maxRetries: 2,
        timeoutMs: 1000,
        retryDelayMs: 100
      });
      
      // Advance timers for all retries (jitter = 0 with Math.random mock)
      await vi.advanceTimersByTimeAsync(100); // First retry
      await vi.advanceTimersByTimeAsync(200); // Second retry
      
      // Await the promise rejection to handle it
      try {
        await resultPromise;
        expect.fail('Should have thrown error');
      } catch (error) {
        expect(error).toHaveProperty('message', 'Always fails');
      }
      expect(fn).toHaveBeenCalledTimes(3); // Initial + 2 retries
    } finally {
      vi.restoreAllMocks();
    }
  });
  
  it('should not retry TimeoutError', async () => {
    const fn = vi.fn().mockImplementation(() => {
      return new Promise((resolve) => {
        setTimeout(() => resolve('success'), 2000);
      });
    });
    
    const resultPromise = withRetry(fn, {
      maxRetries: 3,
      timeoutMs: 100,
      retryDelayMs: 50
    });
    
    // Advance past timeout
    await vi.advanceTimersByTimeAsync(101);
    
    // Await the promise rejection to handle it
    try {
      await resultPromise;
      expect.fail('Should have thrown TimeoutError');
    } catch (error) {
      expect(error).toBeInstanceOf(TimeoutError);
    }
    expect(fn).toHaveBeenCalledTimes(1); // No retries on timeout
    
    // Advance remaining time to clear inner timeout
    await vi.advanceTimersByTimeAsync(1900);
  });
  
  it('should respect shouldRetry predicate', async () => {
    const fn = vi.fn().mockRejectedValue({ statusCode: 400 });
    
    const resultPromise = withRetry(fn, {
      maxRetries: 3,
      timeoutMs: 1000,
      shouldRetry: (error) => {
        // Only retry 5xx errors
        return (error as { statusCode: number }).statusCode >= 500;
      }
    });
    
    await expect(resultPromise).rejects.toEqual({ statusCode: 400 });
    expect(fn).toHaveBeenCalledTimes(1); // No retries for 4xx
  });
  
  it('should use exponential backoff (deterministic, zero-jitter)', async () => {
    // Mock Math.random to return 0 so jitter term is 0 and timer values are exact
    vi.spyOn(Math, 'random').mockReturnValue(0);
    try {
      const fn = vi.fn()
        .mockRejectedValueOnce(new Error('Fail 1'))
        .mockRejectedValueOnce(new Error('Fail 2'))
        .mockResolvedValue('success');
      
      const resultPromise = withRetry(fn, {
        maxRetries: 2,
        timeoutMs: 1000,
        retryDelayMs: 100
      });
      
      // First retry: min(100ms * 2^0, 30000) + jitter(0) = 100ms
      await vi.advanceTimersByTimeAsync(100);
      expect(fn).toHaveBeenCalledTimes(2);
      
      // Second retry: min(100ms * 2^1, 30000) + jitter(0) = 200ms
      await vi.advanceTimersByTimeAsync(200);
      expect(fn).toHaveBeenCalledTimes(3);
      
      await expect(resultPromise).resolves.toBe('success');
    } finally {
      vi.restoreAllMocks();
    }
  });

  it('should add jitter to retry delay', async () => {
    // With Math.random() = 0.5, jitter = baseDelay * 0.1 * 0.5 = 5% of base
    // For retryDelayMs=100: scheduled delay = 100 + 100*0.1*0.5 = 105ms
    vi.spyOn(Math, 'random').mockReturnValue(0.5);
    try {
      const fn = vi.fn()
        .mockRejectedValueOnce(new Error('Fail'))
        .mockResolvedValue('success');

      const resultPromise = withRetry(fn, { maxRetries: 1, retryDelayMs: 100 });

      // At 104ms the retry has NOT fired yet (needs 105ms)
      await vi.advanceTimersByTimeAsync(104);
      expect(fn).toHaveBeenCalledTimes(1);

      // At 106ms (total) the retry fires: 105ms base+jitter has elapsed
      await vi.advanceTimersByTimeAsync(2);
      expect(fn).toHaveBeenCalledTimes(2);

      await expect(resultPromise).resolves.toBe('success');
    } finally {
      vi.restoreAllMocks();
    }
  });

  it('should cap retry delay at maxDelayMs', async () => {
    // With jitter=0 and maxDelayMs=150, delays are capped at 150ms
    vi.spyOn(Math, 'random').mockReturnValue(0);
    try {
      const fn = vi.fn()
        .mockRejectedValueOnce(new Error('Fail 1'))
        .mockRejectedValueOnce(new Error('Fail 2'))
        .mockRejectedValueOnce(new Error('Fail 3'))
        .mockResolvedValue('success');

      const resultPromise = withRetry(fn, {
        maxRetries: 3,
        retryDelayMs: 100,
        maxDelayMs: 150, // uncapped 2nd delay would be 200ms, 3rd = 400ms
      });

      // Attempt 0 → delay min(100*2^0, 150) = 100ms; fn was called once so far
      await vi.advanceTimersByTimeAsync(100);
      expect(fn).toHaveBeenCalledTimes(2);

      // Attempt 1 → delay min(100*2^1, 150) = 150ms (capped from 200ms)
      await vi.advanceTimersByTimeAsync(150);
      expect(fn).toHaveBeenCalledTimes(3);

      // Attempt 2 → delay min(100*2^2, 150) = 150ms (capped from 400ms)
      await vi.advanceTimersByTimeAsync(150);
      expect(fn).toHaveBeenCalledTimes(4);

      await expect(resultPromise).resolves.toBe('success');
    } finally {
      vi.restoreAllMocks();
    }
  });

  it('should throw error for negative maxRetries', async () => {
    const fn = vi.fn();
    
    await expect(withRetry(fn, {
      maxRetries: -1,
      timeoutMs: 1000
    })).rejects.toThrow('maxRetries must be non-negative');
  });

  it('should throw error for zero or negative timeoutMs when provided', async () => {
    const fn = vi.fn();
    
    await expect(withRetry(fn, {
      maxRetries: 2,
      timeoutMs: 0
    })).rejects.toThrow('timeoutMs must be positive');

    await expect(withRetry(fn, {
      maxRetries: 2,
      timeoutMs: -100
    })).rejects.toThrow('timeoutMs must be positive');
  });

  it('should allow omitting timeoutMs when fn handles timeout internally', async () => {
    const fn = vi.fn().mockResolvedValue('success');
    
    const result = await withRetry(fn, {
      maxRetries: 2,
      retryDelayMs: 100
    });

    expect(result).toBe('success');
    expect(fn).toHaveBeenCalledTimes(1);
  });

  it('should throw error for zero or negative retryDelayMs', async () => {
    const fn = vi.fn();
    
    await expect(withRetry(fn, {
      maxRetries: 2,
      timeoutMs: 1000,
      retryDelayMs: 0
    })).rejects.toThrow('retryDelayMs must be positive');

    await expect(withRetry(fn, {
      maxRetries: 2,
      timeoutMs: 1000,
      retryDelayMs: -100
    })).rejects.toThrow('retryDelayMs must be positive');
  });

  it('should throw error for zero or negative maxDelayMs', async () => {
    const fn = vi.fn();

    await expect(withRetry(fn, {
      maxRetries: 2,
      timeoutMs: 1000,
      maxDelayMs: 0
    })).rejects.toThrow('maxDelayMs must be positive');

    await expect(withRetry(fn, {
      maxRetries: 2,
      timeoutMs: 1000,
      maxDelayMs: -500
    })).rejects.toThrow('maxDelayMs must be positive');
  });

  it('should throw error for NaN retryDelayMs or maxDelayMs', async () => {
    const fn = vi.fn();

    await expect(withRetry(fn, {
      maxRetries: 2,
      retryDelayMs: Number.NaN
    })).rejects.toThrow('retryDelayMs must be positive');

    await expect(withRetry(fn, {
      maxRetries: 2,
      maxDelayMs: Number.NaN
    })).rejects.toThrow('maxDelayMs must be positive');
  });
});

describe('isTimeoutError', () => {
  it('should return true for TimeoutError', () => {
    const error = new TimeoutError('Test');
    
    expect(isTimeoutError(error)).toBe(true);
  });
  
  it('should return false for regular Error', () => {
    const error = new Error('Test');
    
    expect(isTimeoutError(error)).toBe(false);
  });
  
  it('should return false for non-Error values', () => {
    expect(isTimeoutError('string')).toBe(false);
    expect(isTimeoutError(null)).toBe(false);
    expect(isTimeoutError(undefined)).toBe(false);
    expect(isTimeoutError({})).toBe(false);
  });
});

describe('withTimeoutConfig', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.clearAllTimers();
    vi.useRealTimers();
  });

  it('should resolve when promise completes before config.timeoutMs', async () => {
    const { withTimeoutConfig } = await import('./timeout.js');
    const resultPromise = withTimeoutConfig(Promise.resolve('ok'), { timeoutMs: 1000 });
    await expect(resultPromise).resolves.toBe('ok');
  });

  it('should throw synchronously for timeoutMs <= 0', async () => {
    const { withTimeoutConfig } = await import('./timeout.js');
    await expect(withTimeoutConfig(Promise.resolve('x'), { timeoutMs: 0 })).rejects.toThrow(
      'timeoutMs must be > 0'
    );
  });

  it('should reject with TimeoutError using operationName in message', async () => {
    const { withTimeoutConfig } = await import('./timeout.js');
    const slow = new Promise((resolve) => setTimeout(() => resolve('slow'), 2000));
    const resultPromise = withTimeoutConfig(slow, { timeoutMs: 500, operationName: 'fetchMEPs' });
    await vi.advanceTimersByTimeAsync(501);
    try {
      await resultPromise;
      expect.fail('Should have thrown');
    } catch (error) {
      expect(error).toBeInstanceOf(TimeoutError);
      expect((error as TimeoutError).message).toContain('fetchMEPs');
      expect((error as TimeoutError).message).toContain('500ms');
    }
    await vi.advanceTimersByTimeAsync(1500);
  });

  it('should use custom errorMessage when provided', async () => {
    const { withTimeoutConfig } = await import('./timeout.js');
    const slow = new Promise((resolve) => setTimeout(() => resolve('slow'), 2000));
    const resultPromise = withTimeoutConfig(slow, { timeoutMs: 500, errorMessage: 'My custom error' });
    await vi.advanceTimersByTimeAsync(501);
    try {
      await resultPromise;
      expect.fail('Should have thrown');
    } catch (error) {
      expect((error as Error).message).toBe('My custom error');
    }
    await vi.advanceTimersByTimeAsync(1500);
  });
});
