import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BaseApiResponse, HttpClientOptions } from '../src/clients';
import { BaseHttpClient } from '../src/clients/base-client';
import type { ITransport, RouteTransportMode } from '../src/clients/transport';
import type { ErrorResponse } from '../src/errors';
import {
  CommandError,
  FileNotFoundError,
  FileSystemError,
  PermissionDeniedError,
  SandboxError
} from '../src/errors';

interface TestDataResponse extends BaseApiResponse {
  data: string;
}

interface TestResourceResponse extends BaseApiResponse {
  id: string;
}

interface TestSourceResponse extends BaseApiResponse {
  source: string;
}

interface TestStatusResponse extends BaseApiResponse {
  status: string;
}

class MockTransport implements ITransport {
  public fetchMock =
    vi.fn<(path: string, options?: RequestInit) => Promise<Response>>();
  public fetchStreamMock =
    vi.fn<
      (
        path: string,
        body?: unknown,
        method?: 'GET' | 'POST',
        headers?: Record<string, string>
      ) => Promise<ReadableStream<Uint8Array>>
    >();
  public connectMock = vi.fn<() => Promise<void>>();
  public disconnectMock = vi.fn<() => void>();
  public isConnectedMock = vi.fn<() => boolean>();
  public setRetryTimeoutMsMock = vi.fn<(ms: number) => void>();

  constructor(private mode: RouteTransportMode = 'http') {
    this.connectMock.mockResolvedValue(undefined);
    this.isConnectedMock.mockReturnValue(true);
  }

  fetch(path: string, options?: RequestInit): Promise<Response> {
    return this.fetchMock(path, options);
  }

  fetchStream(
    path: string,
    body?: unknown,
    method?: 'GET' | 'POST',
    headers?: Record<string, string>
  ): Promise<ReadableStream<Uint8Array>> {
    return this.fetchStreamMock(path, body, method, headers);
  }

  getMode(): RouteTransportMode {
    return this.mode;
  }

  connect(): Promise<void> {
    return this.connectMock();
  }

  disconnect(): void {
    this.disconnectMock();
  }

  isConnected(): boolean {
    return this.isConnectedMock();
  }

  setRetryTimeoutMs(ms: number): void {
    this.setRetryTimeoutMsMock(ms);
  }
}

class TestHttpClient extends BaseHttpClient {
  constructor(options: HttpClientOptions = {}) {
    super({
      baseUrl: 'http://test.com',
      port: 3000,
      ...options
    });
  }

  public async testRequest<T = BaseApiResponse>(
    endpoint: string,
    data?: Record<string, unknown>
  ): Promise<T> {
    if (data) {
      return this.post<T>(endpoint, data);
    }
    return this.get<T>(endpoint);
  }

  public async testStreamRequest(endpoint: string): Promise<ReadableStream> {
    const response = await this.doFetch(endpoint);
    return await this.handleStreamResponse(response);
  }

  public async testDoStreamFetch(
    endpoint: string,
    body?: unknown,
    method: 'GET' | 'POST' = 'POST'
  ): Promise<ReadableStream<Uint8Array>> {
    return await this.doStreamFetch(endpoint, body, method);
  }

  public async testErrorHandling(errorResponse: ErrorResponse) {
    const response = new Response(JSON.stringify(errorResponse), {
      status: errorResponse.httpStatus || 400
    });
    return this.handleErrorResponse(response);
  }
}

type ClientRequestInit = RequestInit & {
  headers: Record<string, string>;
  body: string;
};
type FetchMock = Mock<
  (url: string, options: ClientRequestInit) => Promise<Response>
>;
type ErrorCallbackMock = Mock<(error: string, command?: string) => void>;

describe('BaseHttpClient', () => {
  let client: TestHttpClient;
  let mockFetch: FetchMock;
  let onError: ErrorCallbackMock;

  beforeEach(() => {
    vi.clearAllMocks();

    mockFetch =
      vi.fn<(url: string, options: ClientRequestInit) => Promise<Response>>();
    global.fetch = mockFetch as unknown as typeof fetch;
    onError = vi.fn<(error: string, command?: string) => void>();

    client = new TestHttpClient({
      baseUrl: 'http://test.com',
      port: 3000,
      onError
    });
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  describe('core request functionality', () => {
    it('should handle successful API requests', async () => {
      mockFetch.mockResolvedValue(
        new Response(
          JSON.stringify({ success: true, data: 'operation completed' }),
          { status: 200 }
        )
      );

      const result = await client.testRequest<TestDataResponse>('/api/test');

      expect(result.success).toBe(true);
      expect(result.data).toBe('operation completed');
    });

    it('should handle POST requests with data', async () => {
      const requestData = { action: 'create', name: 'test-resource' };
      mockFetch.mockResolvedValue(
        new Response(JSON.stringify({ success: true, id: 'resource-123' }), {
          status: 201
        })
      );

      const result = await client.testRequest<TestResourceResponse>(
        '/api/create',
        requestData
      );

      expect(result.success).toBe(true);
      expect(result.id).toBe('resource-123');

      const [url, options] = mockFetch.mock.calls[0];
      expect(url).toBe('http://test.com/api/create');
      expect(options.method).toBe('POST');
      expect(options.headers['Content-Type']).toBe('application/json');
      expect(JSON.parse(options.body)).toEqual(requestData);
    });
  });

  describe('error handling and mapping', () => {
    it('should map container errors to client errors', async () => {
      const errorMappingTests = [
        {
          containerError: {
            code: 'FILE_NOT_FOUND',
            message: 'File not found: /test.txt',
            context: { path: '/test.txt' },
            httpStatus: 404,
            timestamp: new Date().toISOString()
          },
          expectedError: FileNotFoundError
        },
        {
          containerError: {
            code: 'PERMISSION_DENIED',
            message: 'Permission denied',
            context: { path: '/secure.txt' },
            httpStatus: 403,
            timestamp: new Date().toISOString()
          },
          expectedError: PermissionDeniedError
        },
        {
          containerError: {
            code: 'COMMAND_EXECUTION_ERROR',
            message: 'Command failed: badcmd',
            context: { command: 'badcmd' },
            httpStatus: 400,
            timestamp: new Date().toISOString()
          },
          expectedError: CommandError
        },
        {
          containerError: {
            code: 'FILESYSTEM_ERROR',
            message: 'Filesystem error',
            context: { path: '/test' },
            httpStatus: 500,
            timestamp: new Date().toISOString()
          },
          expectedError: FileSystemError
        },
        {
          containerError: {
            code: 'UNKNOWN_ERROR',
            message: 'Unknown error',
            context: {},
            httpStatus: 500,
            timestamp: new Date().toISOString()
          },
          expectedError: SandboxError
        }
      ];

      for (const test of errorMappingTests) {
        await expect(
          client.testErrorHandling(test.containerError as ErrorResponse)
        ).rejects.toThrow(test.expectedError);

        expect(onError).toHaveBeenCalledWith(
          test.containerError.message,
          undefined
        );
      }
    });

    it('should handle malformed error responses', async () => {
      mockFetch.mockResolvedValue(
        new Response('invalid json {', { status: 500 })
      );

      await expect(client.testRequest('/api/test')).rejects.toThrow(
        SandboxError
      );
    });

    it('should handle network failures', async () => {
      mockFetch.mockRejectedValue(new Error('Network connection timeout'));

      await expect(client.testRequest('/api/test')).rejects.toThrow(
        'Network connection timeout'
      );
    });

    it('should handle server unavailable scenarios', async () => {
      // Note: 503 triggers container retry loop (transient errors)
      // For permanent server errors, use 500
      mockFetch.mockResolvedValue(
        new Response('Internal Server Error', { status: 500 })
      );

      await expect(client.testRequest('/api/test')).rejects.toThrow(
        SandboxError
      );

      expect(onError).toHaveBeenCalledWith(
        'HTTP error! status: 500',
        undefined
      );
    });
  });

  describe('streaming functionality', () => {
    it('should handle streaming responses', async () => {
      const streamData = 'data: {"type":"output","content":"stream data"}\n\n';
      const mockStream = new ReadableStream({
        start(controller) {
          controller.enqueue(new TextEncoder().encode(streamData));
          controller.close();
        }
      });

      mockFetch.mockResolvedValue(
        new Response(mockStream, {
          status: 200,
          headers: { 'Content-Type': 'text/event-stream' }
        })
      );

      const stream = await client.testStreamRequest('/api/stream');

      expect(stream).toBeInstanceOf(ReadableStream);

      const reader = stream.getReader();
      const { done, value } = await reader.read();
      const content = new TextDecoder().decode(value);

      expect(done).toBe(false);
      expect(content).toContain('stream data');

      reader.releaseLock();
    });

    it('should handle streaming errors', async () => {
      mockFetch.mockResolvedValue(
        new Response(
          JSON.stringify({
            error: 'Stream initialization failed',
            code: 'STREAM_ERROR'
          }),
          { status: 400 }
        )
      );

      await expect(client.testStreamRequest('/api/bad-stream')).rejects.toThrow(
        SandboxError
      );
    });

    it('should handle missing stream body', async () => {
      mockFetch.mockResolvedValue(
        new Response(null, {
          status: 200,
          headers: { 'Content-Type': 'text/event-stream' }
        })
      );

      try {
        await client.testStreamRequest('/api/empty-stream');
        throw new Error('Expected stream request to fail');
      } catch (error) {
        expect(error).toBeInstanceOf(Error);
        expect((error as Error).message).toBe('No response body for streaming');
      }
    });
  });

  describe('defaultHeaders', () => {
    it('should merge defaultHeaders into every request', async () => {
      mockFetch.mockResolvedValue(
        new Response(JSON.stringify({ success: true, data: 'ok' }), {
          status: 200
        })
      );

      const clientWithHeaders = new TestHttpClient({
        baseUrl: 'http://test.com',
        port: 3000,
        defaultHeaders: {
          'X-Sandbox-Id': 'sandbox-abc123',
          'X-Custom-Header': 'custom-value'
        }
      });

      await clientWithHeaders.testRequest('/api/test');

      const [, options] = mockFetch.mock.calls[0];
      expect(options.headers['X-Sandbox-Id']).toBe('sandbox-abc123');
      expect(options.headers['X-Custom-Header']).toBe('custom-value');
    });

    it('should not add extra headers when defaultHeaders is not set', async () => {
      mockFetch.mockResolvedValue(
        new Response(JSON.stringify({ success: true, data: 'ok' }), {
          status: 200
        })
      );

      await client.testRequest('/api/test');

      const [, options] = mockFetch.mock.calls[0];
      const headers = options.headers ?? {};
      expect(headers['X-Sandbox-Id']).toBeUndefined();
    });

    it('should not allow defaultHeaders to override Content-Type on POST', async () => {
      mockFetch.mockResolvedValue(
        new Response(JSON.stringify({ success: true, id: '1' }), {
          status: 201
        })
      );

      const clientWithHeaders = new TestHttpClient({
        baseUrl: 'http://test.com',
        port: 3000,
        defaultHeaders: {
          'X-Sandbox-Id': 'sandbox-xyz',
          'Content-Type': 'text/plain' // should be overridden by POST logic
        }
      });

      await clientWithHeaders.testRequest('/api/create', { name: 'test' });

      const [, options] = mockFetch.mock.calls[0];
      // POST Content-Type should always be application/json regardless of defaultHeaders
      expect(options.headers['Content-Type']).toBe('application/json');
      // Custom header should still be present
      expect(options.headers['X-Sandbox-Id']).toBe('sandbox-xyz');
    });

    it('should pass defaultHeaders to websocket transport requests', async () => {
      const transport = new MockTransport('websocket');
      transport.fetchMock.mockResolvedValue(
        new Response(JSON.stringify({ success: true, data: 'ok' }), {
          status: 200
        })
      );

      const clientWithHeaders = new TestHttpClient({
        transport,
        defaultHeaders: {
          'X-Sandbox-Id': 'sandbox-ws',
          'X-Custom-Header': 'custom-value'
        }
      });

      await clientWithHeaders.testRequest('/api/test');

      const [, options] = transport.fetchMock.mock.calls[0]!;
      expect(options?.headers).toEqual({
        'X-Sandbox-Id': 'sandbox-ws',
        'X-Custom-Header': 'custom-value'
      });
    });

    it('should pass defaultHeaders to websocket streaming requests', async () => {
      const transport = new MockTransport('websocket');
      transport.fetchStreamMock.mockResolvedValue(
        new ReadableStream<Uint8Array>()
      );

      const clientWithHeaders = new TestHttpClient({
        transport,
        defaultHeaders: {
          'X-Sandbox-Id': 'sandbox-stream'
        }
      });

      await clientWithHeaders.testDoStreamFetch('/api/execute/stream', {
        command: 'echo hello'
      });

      const [, body, method, headers] =
        transport.fetchStreamMock.mock.calls[0]!;
      expect(body).toEqual({ command: 'echo hello' });
      expect(method).toBe('POST');
      expect(headers).toEqual({
        'X-Sandbox-Id': 'sandbox-stream',
        'Content-Type': 'application/json'
      });
    });
  });

  describe('stub integration', () => {
    it('should use stub when provided instead of fetch', async () => {
      const stubFetch = vi.fn().mockResolvedValue(
        new Response(JSON.stringify({ success: true, source: 'stub' }), {
          status: 200
        })
      );

      const stub = { containerFetch: stubFetch, fetch: vi.fn() };
      const stubClient = new TestHttpClient({
        baseUrl: 'http://test.com',
        port: 3000,
        stub
      });

      const result =
        await stubClient.testRequest<TestSourceResponse>('/api/stub-test');

      expect(result.success).toBe(true);
      expect(result.source).toBe('stub');
      expect(stubFetch).toHaveBeenCalledWith(
        'http://localhost:3000/api/stub-test',
        { method: 'GET' },
        3000
      );
      expect(mockFetch).not.toHaveBeenCalled();
    });

    it('should handle stub errors', async () => {
      const stubFetch = vi
        .fn()
        .mockRejectedValue(new Error('Stub connection failed'));
      const stub = { containerFetch: stubFetch, fetch: vi.fn() };
      const stubClient = new TestHttpClient({
        baseUrl: 'http://test.com',
        port: 3000,
        stub
      });

      await expect(stubClient.testRequest('/api/stub-error')).rejects.toThrow(
        'Stub connection failed'
      );
    });
  });

  describe('edge cases and resilience', () => {
    it('should handle responses with unusual status codes', async () => {
      const unusualStatusTests = [
        { status: 201, shouldSucceed: true },
        { status: 202, shouldSucceed: true },
        { status: 409, shouldSucceed: false },
        { status: 422, shouldSucceed: false },
        { status: 429, shouldSucceed: false }
      ];

      for (const test of unusualStatusTests) {
        mockFetch.mockResolvedValueOnce(
          new Response(
            test.shouldSucceed
              ? JSON.stringify({ success: true, status: test.status })
              : JSON.stringify({ error: `Status ${test.status}` }),
            { status: test.status }
          )
        );

        if (test.shouldSucceed) {
          const result = await client.testRequest<TestStatusResponse>(
            '/api/unusual-status'
          );
          expect(result.success).toBe(true);
          expect(result.status).toBe(test.status);
        } else {
          await expect(
            client.testRequest('/api/unusual-status')
          ).rejects.toThrow();
        }
      }
    });
  });

  describe('container startup retry logic', () => {
    // The client retries ONLY on 503 (Service Unavailable) status.
    // 503 indicates transient errors like container starting up.
    // 500 indicates permanent errors and should NOT be retried.

    it('should retry 503 errors (container starting)', async () => {
      vi.useFakeTimers();

      mockFetch
        .mockResolvedValueOnce(
          new Response('Container is starting. Please retry in a moment.', {
            status: 503,
            headers: { 'Retry-After': '3' }
          })
        )
        .mockResolvedValueOnce(
          new Response(JSON.stringify({ success: true, data: 'recovered' }), {
            status: 200
          })
        );

      const promise = client.testRequest<TestDataResponse>('/api/test');
      await vi.advanceTimersByTimeAsync(5_000);
      const result = await promise;

      expect(mockFetch).toHaveBeenCalledTimes(2);
      expect(result.success).toBe(true);
      expect(result.data).toBe('recovered');

      vi.useRealTimers();
    });

    it('should retry multiple 503 errors until success', async () => {
      vi.useFakeTimers();

      mockFetch
        .mockResolvedValueOnce(
          new Response('Container is starting.', { status: 503 })
        )
        .mockResolvedValueOnce(
          new Response('Container is starting.', { status: 503 })
        )
        .mockResolvedValueOnce(
          new Response(JSON.stringify({ success: true }), { status: 200 })
        );

      const promise = client.testRequest('/api/test');
      await vi.advanceTimersByTimeAsync(15_000);
      const result = await promise;

      expect(mockFetch).toHaveBeenCalledTimes(3);
      expect(result.success).toBe(true);

      vi.useRealTimers();
    });

    it('should NOT retry 500 errors (permanent failures)', async () => {
      mockFetch.mockResolvedValueOnce(
        new Response('Failed to start container: no such image', {
          status: 500
        })
      );

      await expect(client.testRequest('/api/test')).rejects.toThrow();
      expect(mockFetch).toHaveBeenCalledTimes(1); // No retry
    });

    it('should NOT retry 500 errors regardless of message content', async () => {
      // Even if the message mentions transient-sounding errors,
      // 500 status means permanent failure (DO decided it's not recoverable)
      mockFetch.mockResolvedValueOnce(
        new Response('Internal server error: container port not found', {
          status: 500
        })
      );

      await expect(client.testRequest('/api/test')).rejects.toThrow();
      expect(mockFetch).toHaveBeenCalledTimes(1); // No retry
    });

    it('should NOT retry 404 or other non-503 errors', async () => {
      mockFetch.mockResolvedValueOnce(
        new Response('Not found', { status: 404 })
      );

      await expect(client.testRequest('/api/test')).rejects.toThrow();
      expect(mockFetch).toHaveBeenCalledTimes(1);
    });

    it('should respect MIN_TIME_FOR_RETRY_MS and stop retrying', async () => {
      vi.useFakeTimers();

      // Mock responses that would trigger retry
      mockFetch.mockResolvedValue(
        new Response('No container instance available', { status: 503 })
      );

      const expectation = expect(
        client.testRequest('/api/test')
      ).rejects.toThrow();

      // Fast-forward past retry budget (120s)
      await vi.advanceTimersByTimeAsync(125_000);

      // Should eventually give up and throw the 503 error
      await expectation;

      vi.useRealTimers();
    });

    it('should use exponential backoff: 3s, 6s, 12s, 24s, 30s', async () => {
      vi.useFakeTimers();
      const delays: number[] = [];
      let callCount = 0;

      mockFetch.mockImplementation(async () => {
        delays.push(Date.now());
        callCount++;
        // After 5 attempts, return success to avoid timeout
        if (callCount >= 5) {
          return new Response(JSON.stringify({ success: true }), {
            status: 200
          });
        }
        return new Response('No container instance available', { status: 503 });
      });

      const promise = client.testRequest('/api/test');

      // Advance time to allow all retries
      await vi.advanceTimersByTimeAsync(80_000);

      await promise;

      // Check delays between attempts (approximately)
      // Attempt 1 at 0ms, Attempt 2 at ~3000ms, Attempt 3 at ~9000ms, etc.
      expect(delays.length).toBeGreaterThanOrEqual(4);

      vi.useRealTimers();
    });

    it('should retry multiple 503 errors in sequence until success', async () => {
      vi.useFakeTimers();

      // Only 503 triggers retry - 500 does not
      mockFetch
        .mockResolvedValueOnce(
          new Response('Container is starting.', { status: 503 })
        )
        .mockResolvedValueOnce(
          new Response('Container is starting.', { status: 503 })
        )
        .mockResolvedValueOnce(
          new Response('Container is starting.', { status: 503 })
        )
        .mockResolvedValueOnce(
          new Response(JSON.stringify({ success: true }), { status: 200 })
        );

      const promise = client.testRequest('/api/test');

      // Advance time to allow all retries (3s + 6s + 12s = 21s)
      await vi.advanceTimersByTimeAsync(25_000);

      const result = await promise;

      expect(mockFetch).toHaveBeenCalledTimes(4);
      expect(result.success).toBe(true);

      vi.useRealTimers();
    });

    it('should NOT retry on 500 regardless of error message', async () => {
      // Previously this would retry based on message content
      // Now we only retry on 503 status code
      const errorMessages = [
        'Container port not found',
        'The container is not listening',
        'ERROR: CONTAINER PORT NOT FOUND'
      ];

      for (const message of errorMessages) {
        mockFetch.mockResolvedValueOnce(new Response(message, { status: 500 }));

        await expect(client.testRequest('/api/test')).rejects.toThrow();
        expect(mockFetch).toHaveBeenCalledTimes(1);
        mockFetch.mockClear();
      }
    });
  });
});
