/**
 * aiService.core.test.js — coverage for the AIService class core surface.
 *
 * Scope (deliberate, focused):
 *   - Constructor + initial state setup
 *   - Ollama-model detection helper
 *   - Model reliability tracking (success/failure counters, per-model + all)
 *   - Model-related error detection + classification
 *   - Model suggestions (sorted by reliability)
 *   - Circuit breaker state machine (record → open → recovery)
 *   - Catalog-based model resolution by api_type
 *   - setModelsService wiring
 *
 * Out of scope for this file (each gets its own deep test pass):
 *   - sendMessage / sendMessageStream HTTP paths (mock SDK, big surface)
 *   - SSE stream parsing → covered by aiService.stream.test.js
 *   - Embeddings hookup       → covered by aiService.embed.test.js
 *   - _extractReasoningTokens → covered by aiService.reasoningTokens.test.js
 *
 * Pure-method coverage strategy: no network, no SDK mocks needed. Every
 * method we hit here is computational over local state.
 */

import { describe, test, expect, beforeEach, jest } from '@jest/globals';
import AIService from '../aiService.js';

const silentLogger = {
  info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
};

function makeService(overrides = {}) {
  const config = {
    backend: { baseUrl: 'https://test.example/', timeout: 1000 },
    ...overrides,
  };
  return new AIService(config, silentLogger, null, null);
}

// ──────────────────────── constructor ──────────────────────────────

describe('AIService — constructor & initial state', () => {
  test('uses backend.baseUrl from config when provided', () => {
    const svc = makeService();
    expect(svc.baseUrl).toBe('https://test.example/');
    expect(svc.timeout).toBe(1000);
  });

  test('falls back to the documented Azure default when backend not configured', () => {
    const svc = new AIService({}, silentLogger, null, null);
    expect(svc.baseUrl).toMatch(/^https:\/\//);
    expect(svc.timeout).toBeGreaterThan(0);
  });

  test('initial state is consistent: empty maps + closed circuit', () => {
    const svc = makeService();
    expect(svc.rateLimiters).toBeInstanceOf(Map);
    expect(svc.requestQueue).toBeInstanceOf(Map);
    expect(svc.conversationManagers).toBeInstanceOf(Map);
    expect(svc.activeRequests).toBeInstanceOf(Map);
    expect(svc.userAbortedRequests).toBeInstanceOf(Set);
    expect(svc.modelReliability).toBeInstanceOf(Map);
    expect(svc.modelReliability.size).toBe(0);
    expect(svc.circuitBreaker.isOpen).toBe(false);
    expect(svc.circuitBreaker.failures).toBe(0);
    expect(svc.circuitBreaker.threshold).toBeGreaterThan(0);
  });

  test('apiKeyManager / agentPool / embeddingService start unset (LoxiaSystem wires them later)', () => {
    const svc = makeService();
    expect(svc.apiKeyManager).toBeNull();
    expect(svc.agentPool).toBeNull();
    expect(svc.embeddingService).toBeNull();
    expect(svc._ollamaService).toBeNull();
  });

  test('modelSpecs is populated with known model entries', () => {
    const svc = makeService();
    expect(svc.modelSpecs).toEqual(expect.any(Object));
    // Has SOME model entries with provider + contextWindow shape
    const sample = Object.values(svc.modelSpecs)[0];
    expect(sample).toMatchObject({
      provider: expect.any(String),
      contextWindow: expect.any(Number),
      maxTokens: expect.any(Number),
    });
  });
});

// ──────────────────── _isOllamaModel ──────────────────────────────

describe('AIService — _isOllamaModel', () => {
  const svc = makeService();
  // True paths assert strict true; false paths use truthy/falsy because the
  // implementation short-circuits with `model && model.startsWith(...)` —
  // a falsy `model` short-circuits to the original value (null/undef/'')
  // rather than coercing to literal false. Either is fine for callers since
  // the function is always used in a boolean context.
  test.each([
    ['ollama-llama3',  true],
    ['ollama-mistral', true],
    ['ollama-',        true], // prefix-only is technically a hit
  ])('_isOllamaModel(%p) is true', (input, expected) => {
    expect(svc._isOllamaModel(input)).toBe(expected);
  });

  test.each([
    ['llama3'],
    ['gpt-4'],
    ['claude-sonnet'],
    [''],
    [null],
    [undefined],
  ])('_isOllamaModel(%p) is falsy', (input) => {
    expect(svc._isOllamaModel(input)).toBeFalsy();
  });
});

// ─────────────────── reliability tracking ─────────────────────────

describe('AIService — reliability tracking', () => {
  let svc;
  beforeEach(() => { svc = makeService(); });

  test('unknown model returns the zero-state shape', () => {
    expect(svc.getModelReliability('never-used')).toEqual({
      verified: false, successCount: 0, failureCount: 0,
    });
  });

  test('recordModelSuccess increments + marks verified', () => {
    svc.recordModelSuccess('gpt-4');
    const r = svc.getModelReliability('gpt-4');
    expect(r.verified).toBe(true);
    expect(r.successCount).toBe(1);
    expect(r.failureCount).toBe(0);
    expect(r.lastSuccess).toBeInstanceOf(Date);
  });

  test('multiple successes accumulate', () => {
    svc.recordModelSuccess('gpt-4');
    svc.recordModelSuccess('gpt-4');
    svc.recordModelSuccess('gpt-4');
    expect(svc.getModelReliability('gpt-4').successCount).toBe(3);
  });

  test('recordModelFailure increments + records errorType', () => {
    svc.recordModelFailure('gpt-4', 'rate_limit');
    const r = svc.getModelReliability('gpt-4');
    expect(r.failureCount).toBe(1);
    expect(r.lastErrorType).toBe('rate_limit');
    expect(r.lastFailure).toBeInstanceOf(Date);
  });

  test('failures alone do NOT verify the model', () => {
    svc.recordModelFailure('gpt-4', 'unknown');
    expect(svc.getModelReliability('gpt-4').verified).toBe(false);
  });

  test('errorType defaults to "unknown" when omitted', () => {
    svc.recordModelFailure('gpt-4');
    expect(svc.getModelReliability('gpt-4').lastErrorType).toBe('unknown');
  });

  test('per-model state stays isolated', () => {
    svc.recordModelSuccess('gpt-4');
    svc.recordModelFailure('claude-sonnet', 'rate_limit');
    expect(svc.getModelReliability('gpt-4').successCount).toBe(1);
    expect(svc.getModelReliability('gpt-4').failureCount).toBe(0);
    expect(svc.getModelReliability('claude-sonnet').failureCount).toBe(1);
    expect(svc.getModelReliability('claude-sonnet').successCount).toBe(0);
  });

  test('getAllModelReliability returns the public-shape map', () => {
    svc.recordModelSuccess('gpt-4');
    svc.recordModelFailure('gpt-4', 'rate_limit');
    svc.recordModelFailure('claude-sonnet', 'auth_error');
    const all = svc.getAllModelReliability();
    expect(Object.keys(all).sort()).toEqual(['claude-sonnet', 'gpt-4']);
    expect(all['gpt-4']).toEqual({
      verified: true, successCount: 1, failureCount: 1,
    });
    expect(all['claude-sonnet']).toEqual({
      verified: false, successCount: 0, failureCount: 1,
    });
  });

  test('getAllModelReliability returns {} when nothing recorded', () => {
    expect(svc.getAllModelReliability()).toEqual({});
  });
});

// ────────────────── setModelsService ──────────────────────────────

describe('AIService — setModelsService', () => {
  test('attaches the modelsService reference', () => {
    const svc = makeService();
    expect(svc.modelsService).toBeUndefined();
    const ms = { getModels: () => [] };
    svc.setModelsService(ms);
    expect(svc.modelsService).toBe(ms);
  });
});

// ──────────────── isModelRelatedError ────────────────────────────

describe('AIService — isModelRelatedError', () => {
  const svc = makeService();

  test.each([
    [{ status: 429, message: 'whatever' }, true],
    [{ status: 503, message: 'whatever' }, true],
    [{ code: 404,    message: 'whatever' }, true],
    [{ message: 'Model "x" not found' },           true],
    [{ message: 'model is unavailable' },          true],
    [{ message: 'Model does not exist' },          true],
    [{ message: 'invalid model name' },            true],
    [{ message: 'Unsupported model variant' },     true],
    [{ message: 'Quota exceeded for org' },        true],
    [{ message: 'Hit a rate limit' },              true],
    [{ message: 'Context length exceeded' },       true],
    [{ message: 'Token limit reached' },           true],
    [{ message: 'maximum context window' },        true],
    [{ message: 'service capacity issue' },        true],
    [{ message: 'model overloaded right now' },    true],
    [{ message: 'returned 503 internal' },         true],
    [{ message: 'got 429 from upstream' },         true],
    [{ message: 'something completely unrelated' }, false],
    [{ message: '' },                               false],
    [{ status: 500, message: 'server error' },      false],
  ])('isModelRelatedError(%p) → %p', (err, expected) => {
    expect(svc.isModelRelatedError(err)).toBe(expected);
  });
});

// ───────────────── classifyModelError ────────────────────────────

describe('AIService — classifyModelError', () => {
  const svc = makeService();

  test.each([
    [{ code: 404 },                                   'model_not_found'],
    [{ message: 'model not found' },                   'model_not_found'],
    [{ message: 'thing does not exist' },              'model_not_found'],
    [{ code: 429 },                                    'rate_limit'],
    [{ message: 'rate limit reached' },                'rate_limit'],
    [{ message: 'quota was hit' },                     'rate_limit'],
    [{ message: 'maximum context exceeded' },          'context_exceeded'],
    [{ message: 'token limit too small' },             'context_exceeded'],
    [{ code: 503 },                                    'model_overloaded'],
    [{ message: 'model overload' },                    'model_overloaded'],
    [{ message: 'capacity full right now' },           'model_overloaded'],
    [{ code: 401 },                                    'auth_error'],
    [{ code: 403 },                                    'auth_error'],
    [{ code: 500, message: 'whoops' },                 'unknown'],
    [{ message: '' },                                  'unknown'],
  ])('classifyModelError(%p) → %p', (err, expected) => {
    expect(svc.classifyModelError(err)).toBe(expected);
  });

  test('status field is treated equivalent to code field', () => {
    expect(svc.classifyModelError({ status: 404 })).toBe('model_not_found');
    expect(svc.classifyModelError({ status: 429 })).toBe('rate_limit');
  });
});

// ───────────────── getModelSuggestions ───────────────────────────

describe('AIService — getModelSuggestions', () => {
  test('returns empty suggestions when no modelsService is attached', () => {
    const svc = makeService();
    const out = svc.getModelSuggestions('gpt-4', new Error('rate limit'));
    expect(out.failedModel).toBe('gpt-4');
    expect(out.errorType).toBe('rate_limit');
    expect(out.errorMessage).toBe('rate limit');
    expect(out.suggestions).toEqual([]);
  });

  test('excludes the failed model from suggestions', () => {
    const svc = makeService();
    svc.setModelsService({
      getModels: () => [
        { name: 'gpt-4',         displayName: 'GPT-4',         provider: 'openai',    contextWindow: 128000 },
        { name: 'claude-sonnet', displayName: 'Claude Sonnet', provider: 'anthropic', contextWindow: 200000 },
      ],
    });
    const out = svc.getModelSuggestions('gpt-4', new Error('whatever'));
    expect(out.suggestions.map(s => s.name)).toEqual(['claude-sonnet']);
  });

  test('sorts verified models above unverified, then by successCount desc', () => {
    const svc = makeService();
    svc.setModelsService({
      getModels: () => [
        { name: 'A', provider: 'p', contextWindow: 1 },
        { name: 'B', provider: 'p', contextWindow: 1 },
        { name: 'C', provider: 'p', contextWindow: 1 },
        { name: 'D', provider: 'p', contextWindow: 1 },
      ],
    });
    // Only B and D are "verified" (have at least one success)
    svc.recordModelSuccess('B');
    svc.recordModelSuccess('D');
    svc.recordModelSuccess('D'); // D has 2 successes, B has 1
    // C has only a failure → unverified
    svc.recordModelFailure('C', 'rate_limit');

    const out = svc.getModelSuggestions('Z-not-in-list', new Error(''));
    const order = out.suggestions.map(s => s.name);
    // Verified first, sorted by success count: D (2), B (1), then unverified A & C
    expect(order.slice(0, 2)).toEqual(['D', 'B']);
    expect(order.slice(2).sort()).toEqual(['A', 'C']); // tail order among unverified is impl detail
  });

  test('errorType is derived via classifyModelError', () => {
    const svc = makeService();
    svc.setModelsService({ getModels: () => [] });
    const e = new Error('overloaded'); e.code = 503;
    expect(svc.getModelSuggestions('x', e).errorType).toBe('model_overloaded');
  });
});

// ────────────────── circuit breaker ──────────────────────────────

describe('AIService — circuit breaker', () => {
  let svc;
  beforeEach(() => { svc = makeService(); });

  test('starts closed', () => {
    expect(svc._isCircuitBreakerOpen()).toBe(false);
  });

  test('opens after `threshold` consecutive failures', () => {
    const threshold = svc.circuitBreaker.threshold;
    for (let i = 0; i < threshold; i++) svc._recordFailure();
    expect(svc.circuitBreaker.isOpen).toBe(true);
    expect(svc._isCircuitBreakerOpen()).toBe(true);
  });

  test('stays closed below threshold', () => {
    svc._recordFailure();
    svc._recordFailure();
    expect(svc._isCircuitBreakerOpen()).toBe(false);
  });

  test('_isCircuitBreakerOpen auto-recovers after the timeout window', () => {
    // Trip the breaker.
    for (let i = 0; i < svc.circuitBreaker.threshold; i++) svc._recordFailure();
    expect(svc.circuitBreaker.isOpen).toBe(true);

    // Pretend the last failure was longer ago than the recovery window.
    svc.circuitBreaker.lastFailureTime = Date.now() - svc.circuitBreaker.timeout - 1;
    expect(svc._isCircuitBreakerOpen()).toBe(false);
    // Resetting also clears failure count.
    expect(svc.circuitBreaker.failures).toBe(0);
  });

  test('_resetCircuitBreaker zeroes counters + closes', () => {
    svc._recordFailure();
    svc._recordFailure();
    svc._resetCircuitBreaker();
    expect(svc.circuitBreaker.failures).toBe(0);
    expect(svc.circuitBreaker.isOpen).toBe(false);
  });

  test('_resetCircuitBreaker on already-clean state is a no-op (no log spam)', () => {
    silentLogger.info.mockClear();
    svc._resetCircuitBreaker();
    expect(silentLogger.info).not.toHaveBeenCalled();
  });

  test('lastFailureTime is updated on each failure', () => {
    const before = Date.now();
    svc._recordFailure();
    expect(svc.circuitBreaker.lastFailureTime).toBeGreaterThanOrEqual(before);
  });
});

// ──────────────── _resolveModelByApiType ─────────────────────────

describe('AIService — _resolveModelByApiType', () => {
  test('returns null when no modelsService is attached', () => {
    expect(makeService()._resolveModelByApiType('image_generation')).toBeNull();
  });

  test('finds models whose api_type array includes the requested type', () => {
    const svc = makeService();
    svc.setModelsService({
      getModels: () => [
        { name: 'chat-1',  api_type: ['chat'] },
        { name: 'image-1', api_type: ['image_generation'] },
        { name: 'image-2', api_type: ['image_generation', 'chat'] },
      ],
    });
    expect(svc._resolveModelByApiType('image_generation')).toBe('image-1');
  });

  test('falls back to modality field when api_type is absent', () => {
    const svc = makeService();
    svc.setModelsService({
      getModels: () => [
        { name: 'video-only', modality: 'video_generation' },
      ],
    });
    expect(svc._resolveModelByApiType('video_generation')).toBe('video-only');
  });

  test('returns null when no model matches', () => {
    const svc = makeService();
    svc.setModelsService({
      getModels: () => [{ name: 'x', api_type: ['chat'] }],
    });
    expect(svc._resolveModelByApiType('nonexistent')).toBeNull();
  });

  test('returns null without throwing when getModels throws', () => {
    const svc = makeService();
    svc.setModelsService({ getModels: () => { throw new Error('boom'); } });
    expect(svc._resolveModelByApiType('chat')).toBeNull();
  });
});
