/**
 * Tests for embedding provider with retry logic.
 * Covers: normal path, network errors, timeouts, and exhausted retries.
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdirSync, mkdtempSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';

// Mock @huggingface/transformers to avoid real downloads
vi.mock('@huggingface/transformers', () => ({
  pipeline: vi.fn(),
  env: {
    remoteHost: '',
    remotePathTemplate: '',
  },
}));

describe('embedding-provider retry logic', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    // Reset module state by re-importing
    vi.resetModules();
  });

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

  describe('mirror configuration', () => {
    it('should configure hf-mirror.com when HF_ENDPOINT is not set', async () => {
      const originalEnv = process.env.HF_ENDPOINT;
      delete process.env.HF_ENDPOINT;

      const { pipeline, env } = await import('@huggingface/transformers');
      // Reset env to simulate fresh state
      env.remoteHost = '';
      env.remotePathTemplate = '';

      const mockPipeline = { mock: true };
      vi.mocked(pipeline).mockResolvedValueOnce(mockPipeline);

      const { downloadLocalModel } = await import('@/lib/core/embedding-provider');
      await downloadLocalModel('test-model');

      // Verify mirror was configured
      expect(env.remoteHost).toBe('https://hf-mirror.com');
      expect(env.remotePathTemplate).toBe('{model}/resolve/{revision}/{fileName}');

      // Restore
      if (originalEnv) process.env.HF_ENDPOINT = originalEnv;
    });

    it('should respect HF_ENDPOINT environment variable', async () => {
      const originalEnv = process.env.HF_ENDPOINT;
      process.env.HF_ENDPOINT = 'https://custom-mirror.com';

      const { pipeline, env } = await import('@huggingface/transformers');
      // Reset env to simulate fresh state
      env.remoteHost = '';
      env.remotePathTemplate = '';

      const mockPipeline = { mock: true };
      vi.mocked(pipeline).mockResolvedValueOnce(mockPipeline);

      const { downloadLocalModel } = await import('@/lib/core/embedding-provider');
      await downloadLocalModel('test-model');

      // Verify custom mirror was NOT overridden (env.remoteHost should remain empty)
      expect(env.remoteHost).toBe('');

      // Restore
      if (originalEnv) {
        process.env.HF_ENDPOINT = originalEnv;
      } else {
        delete process.env.HF_ENDPOINT;
      }
    });

    it('should not override if env.remoteHost is already set', async () => {
      const originalEnv = process.env.HF_ENDPOINT;
      delete process.env.HF_ENDPOINT;

      const { pipeline, env } = await import('@huggingface/transformers');
      // Simulate user has already configured a custom mirror
      env.remoteHost = 'https://user-custom-mirror.com';
      env.remotePathTemplate = 'custom/{model}/{fileName}';

      const mockPipeline = { mock: true };
      vi.mocked(pipeline).mockResolvedValueOnce(mockPipeline);

      const { downloadLocalModel } = await import('@/lib/core/embedding-provider');
      await downloadLocalModel('test-model');

      // Verify user's custom mirror was NOT overridden
      expect(env.remoteHost).toBe('https://user-custom-mirror.com');
      expect(env.remotePathTemplate).toBe('custom/{model}/{fileName}');

      // Restore
      if (originalEnv) process.env.HF_ENDPOINT = originalEnv;
    });
  });

  describe('isRetryableError classification', () => {
    // These would be internal to the module, tested indirectly through loadLocalPipeline behavior
    
    const retryableErrors = [
      new Error('ENOTFOUND ai.example.com:443'),
      new Error('ECONNREFUSED 127.0.0.1:443'),
      new Error('ECONNRESET'),
      new Error('ETIMEDOUT'),
      new Error('Request timeout'),
      new Error('temporarily unavailable'),
    ];

    const nonRetryableErrors = [
      new Error('Model not found (404)'),
      new Error('Invalid model format'),
      new Error('Permission denied'),
    ];

    it('should identify retryable network errors', async () => {
      // Test would validate error classification
      // This is implicitly tested through retry behavior below
      expect(retryableErrors.length).toBe(6);
    });

    it('should identify non-retryable errors', async () => {
      expect(nonRetryableErrors.length).toBe(3);
    });
  });

  describe('loadLocalPipeline with retry', () => {
    it('should succeed on first attempt', async () => {
      const { pipeline } = await import('@huggingface/transformers');
      const mockPipeline = { mock: true };
      
      vi.mocked(pipeline).mockResolvedValueOnce(mockPipeline);

      // Import the module to test
      const { downloadLocalModel } = await import('@/lib/core/embedding-provider');
      const result = await downloadLocalModel('test-model');

      expect(result).toBe(true);
      expect(vi.mocked(pipeline)).toHaveBeenCalledOnce();
    });

    it('should retry on network error and succeed on second attempt', async () => {
      const { pipeline } = await import('@huggingface/transformers');
      const mockPipeline = { mock: true };
      
      // First call: network error (ECONNREFUSED)
      // Second call: success
      vi.mocked(pipeline)
        .mockRejectedValueOnce(new Error('ECONNREFUSED 127.0.0.1:443'))
        .mockResolvedValueOnce(mockPipeline);

      const { downloadLocalModel } = await import('@/lib/core/embedding-provider');
      const result = await downloadLocalModel('test-model');

      expect(result).toBe(true);
      // Should be called twice (one fail, one success)
      expect(vi.mocked(pipeline)).toHaveBeenCalledTimes(2);
    });

    it('should retry on timeout and succeed on second attempt', async () => {
      const { pipeline } = await import('@huggingface/transformers');
      const mockPipeline = { mock: true };
      
      vi.mocked(pipeline)
        .mockRejectedValueOnce(new Error('Request timeout'))
        .mockResolvedValueOnce(mockPipeline);

      const { downloadLocalModel } = await import('@/lib/core/embedding-provider');
      const result = await downloadLocalModel('test-model');

      expect(result).toBe(true);
      expect(vi.mocked(pipeline)).toHaveBeenCalledTimes(2);
    });

    it('should exhaust retries and fail on persistent network error', async () => {
      const { pipeline } = await import('@huggingface/transformers');
      
      // All attempts fail with network error
      vi.mocked(pipeline)
        .mockRejectedValue(new Error('ENOTFOUND hub.huggingface.co'));

      const { downloadLocalModel } = await import('@/lib/core/embedding-provider');
      const result = await downloadLocalModel('test-model');

      expect(result).toBe(false);
      // Should try 3 times (initial + 2 retries = DOWNLOAD_MAX_RETRIES + 1)
      expect(vi.mocked(pipeline)).toHaveBeenCalledTimes(3);
    });

    it('should not retry on non-network errors (e.g., model not found)', async () => {
      const { pipeline } = await import('@huggingface/transformers');
      
      // Non-retryable error
      vi.mocked(pipeline)
        .mockRejectedValue(new Error('Model not found (404)'));

      const { downloadLocalModel } = await import('@/lib/core/embedding-provider');
      const result = await downloadLocalModel('invalid-model');

      expect(result).toBe(false);
      // Should only try once (no retry for non-network errors)
      expect(vi.mocked(pipeline)).toHaveBeenCalledOnce();
    });

    it('should apply exponential backoff between retries', async () => {
      const { pipeline } = await import('@huggingface/transformers');
      const mockPipeline = { mock: true };
      
      // Mock both pipeline and setTimeout to track timing
      const sleep = vi.fn((ms: number) => new Promise(r => setTimeout(r, Math.min(ms, 10))));
      
      vi.mocked(pipeline)
        .mockRejectedValueOnce(new Error('ECONNREFUSED'))
        .mockRejectedValueOnce(new Error('ECONNREFUSED'))
        .mockResolvedValueOnce(mockPipeline);

      const { downloadLocalModel } = await import('@/lib/core/embedding-provider');
      const result = await downloadLocalModel('test-model');

      expect(result).toBe(true);
      // Verify retries happened with backoff
      expect(vi.mocked(pipeline)).toHaveBeenCalledTimes(3);
    });

    it('should cache successful pipeline and reuse for same model', async () => {
      const { pipeline } = await import('@huggingface/transformers');
      const mockPipeline = { mock: true };
      
      vi.mocked(pipeline).mockResolvedValue(mockPipeline);

      const { downloadLocalModel } = await import('@/lib/core/embedding-provider');
      
      // First download
      const result1 = await downloadLocalModel('model-a');
      expect(result1).toBe(true);
      expect(vi.mocked(pipeline)).toHaveBeenCalledTimes(1);

      // Second download same model — should use cache, not call pipeline again
      const result2 = await downloadLocalModel('model-a');
      expect(result2).toBe(true);
      expect(vi.mocked(pipeline)).toHaveBeenCalledTimes(1); // Still 1, not 2
    });

    it('should release cached local pipeline after idle timeout', async () => {
      vi.useFakeTimers();
      const { pipeline } = await import('@huggingface/transformers');
      const mockPipeline = { mock: true };

      vi.mocked(pipeline).mockResolvedValue(mockPipeline);

      const { downloadLocalModel, LOCAL_PIPELINE_IDLE_TTL_MS } = await import('@/lib/core/embedding-provider');

      const result1 = await downloadLocalModel('model-a');
      expect(result1).toBe(true);
      expect(vi.mocked(pipeline)).toHaveBeenCalledTimes(1);

      vi.advanceTimersByTime(LOCAL_PIPELINE_IDLE_TTL_MS);

      const result2 = await downloadLocalModel('model-a');
      expect(result2).toBe(true);
      expect(vi.mocked(pipeline)).toHaveBeenCalledTimes(2);
    });

    it('should handle model switching after cache', async () => {
      const { pipeline } = await import('@huggingface/transformers');
      const mockPipelineA = { mock: 'a' };
      const mockPipelineB = { mock: 'b' };
      
      vi.mocked(pipeline)
        .mockResolvedValueOnce(mockPipelineA)
        .mockResolvedValueOnce(mockPipelineB);

      const { downloadLocalModel } = await import('@/lib/core/embedding-provider');
      
      const result1 = await downloadLocalModel('model-a');
      expect(result1).toBe(true);

      const result2 = await downloadLocalModel('model-b');
      expect(result2).toBe(true);

      // Should call pipeline twice (different models)
      expect(vi.mocked(pipeline)).toHaveBeenCalledTimes(2);
    });
  });

  describe('error message quality', () => {
    it('should provide specific error messages for network issues', async () => {
      // This is tested through the API route's error classification
      // which improves error messages for the UI
      const networkError = new Error('ENOTFOUND hub.huggingface.co');
      expect(networkError.message.toLowerCase()).toContain('enotfound');
    });
  });

  describe('optional runtime installer', () => {
    it('uses bundled npm cli when running from a Desktop runtime', async () => {
      const originalProjectRoot = process.env.MINDOS_PROJECT_ROOT;
      const originalNodeBin = process.env.MINDOS_NODE_BIN;
      const runtimeRoot = mkdtempSync(path.join(tmpdir(), 'mindos-runtime-npm-'));
      const npmCli = path.join(runtimeRoot, 'node', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js');
      mkdirSync(path.dirname(npmCli), { recursive: true });
      writeFileSync(npmCli, '#!/usr/bin/env node\n');
      process.env.MINDOS_PROJECT_ROOT = runtimeRoot;
      process.env.MINDOS_NODE_BIN = '/opt/MindOS/node/bin/node';

      try {
        const { resolveLocalEmbeddingNpmInvocation } = await import('@/lib/core/embedding-provider');
        expect(resolveLocalEmbeddingNpmInvocation()).toEqual({
          command: '/opt/MindOS/node/bin/node',
          args: [npmCli],
        });
      } finally {
        if (originalProjectRoot === undefined) delete process.env.MINDOS_PROJECT_ROOT;
        else process.env.MINDOS_PROJECT_ROOT = originalProjectRoot;
        if (originalNodeBin === undefined) delete process.env.MINDOS_NODE_BIN;
        else process.env.MINDOS_NODE_BIN = originalNodeBin;
      }
    });
  });
});
