/**
 * EmbeddingService orchestrator tests.
 *
 * Pins:
 *   - 'disabled' is a first-class state (consumers can detect via isEnabled).
 *   - Provider is lazy-instantiated based on config; hot-swap via updateConfig.
 *   - VectorStores are per-scope, get-or-created, file-backed under stateDir.
 *   - Telemetry tracks calls + tokens, per-day buckets, and budget enforcement.
 *   - Budget exhaustion throws RATE_LIMITED; local provider is exempt
 *     from the cloud budget cap.
 *   - Health surface reflects provider readiness + budget state.
 *   - Per-scope store path layout matches the documented contract
 *     (agents/<id>/<surface>.vec.json vs. global/<surface>.vec.json).
 */

import { describe, test, expect, jest, beforeEach, afterEach } from '@jest/globals';
import os from 'os';
import fsp from 'fs/promises';
import path from 'path';
import { EmbeddingService } from '../embeddingService.js';
import {
  EmbeddingError,
  EMBEDDING_ERROR_CODES,
  EmbeddingProvider,
} from '../providerInterface.js';

class StubProvider extends EmbeddingProvider {
  constructor({ dimensions = 4, name = 'azure', fingerprint = 'azure:stub-v1' } = {}) {
    super();
    this._info = {
      name,
      modelId: 'stub-model',
      dimensions,
      maxBatchSize: 16,
      maxTokensPerText: 100,
      distinguishesQueryAndDocument: false,
      isLocal: name === 'local',
    };
    this._fingerprint = fingerprint;
    this.calls = 0;
  }
  getInfo() { return this._info; }
  getModelFingerprint() { return this._fingerprint; }
  async _embed(texts) {
    this.calls += texts.length;
    return texts.map((_, i) =>
      Array.from({ length: this._info.dimensions }, (_, j) => (i + 1) * (j + 1) * 0.1));
  }
}

/** Build an EmbeddingService with stub providers + fake apiKeyManager. */
function makeService({ tmpDir, providerName = 'azure', dimensions = 4, configExtra = {} }) {
  const apiKeyManager = {
    getEmbeddingApiKey: jest.fn(() => 'stub-key'),
  };
  const stubProvider = new StubProvider({ name: providerName, dimensions });
  const svc = new EmbeddingService({
    config: { provider: providerName, ...configExtra },
    apiKeyManager,
    baseUrl: 'https://stub-backend.example',
    stateDir: tmpDir,
    providerOverrides: { [providerName]: () => stubProvider },
  });
  return { svc, stubProvider, apiKeyManager };
}

describe('EmbeddingService — disabled by default', () => {
  let tmpDir;
  beforeEach(async () => {
    tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'loxia-emb-svc-'));
  });
  afterEach(async () => {
    await fsp.rm(tmpDir, { recursive: true, force: true });
  });

  test('no config → disabled', () => {
    const svc = new EmbeddingService({
      apiKeyManager: { getEmbeddingApiKey: () => null },
      baseUrl: 'https://x',
      stateDir: tmpDir,
    });
    expect(svc.isEnabled).toBe(false);
  });

  test('disabled state: embedQuery throws NOT_INITIALIZED', async () => {
    const svc = new EmbeddingService({
      apiKeyManager: { getEmbeddingApiKey: () => null },
      baseUrl: 'https://x',
      stateDir: tmpDir,
    });
    await expect(svc.embedQuery('hi')).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.NOT_INITIALIZED,
    });
  });

  test('disabled state: health() returns ok:false with reason', async () => {
    const svc = new EmbeddingService({
      apiKeyManager: { getEmbeddingApiKey: () => null },
      baseUrl: 'https://x',
      stateDir: tmpDir,
    });
    const h = await svc.health();
    expect(h.ok).toBe(false);
    expect(h.reason).toMatch(/disabled/i);
  });

  test('disabled state: getStore throws', async () => {
    const svc = new EmbeddingService({
      apiKeyManager: { getEmbeddingApiKey: () => null },
      baseUrl: 'https://x',
      stateDir: tmpDir,
    });
    await expect(svc.getStore({ agentId: 'a1', surface: 'memory' })).rejects.toThrow(/disabled/i);
  });
});

describe('EmbeddingService — provider routing', () => {
  let tmpDir;
  beforeEach(async () => { tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'loxia-emb-svc-')); });
  afterEach(async () => { await fsp.rm(tmpDir, { recursive: true, force: true }); });

  test('provider=azure: uses azure factory', () => {
    const { svc, stubProvider } = makeService({ tmpDir, providerName: 'azure' });
    expect(svc.getProvider()).toBe(stubProvider);
    expect(svc.getProvider().getInfo().name).toBe('azure');
  });

  test('provider lookup is lazy (no work until first request)', () => {
    const apiKeyManager = { getEmbeddingApiKey: jest.fn(() => 'k') };
    const factory = jest.fn(() => new StubProvider());
    const svc = new EmbeddingService({
      config: { provider: 'azure' },
      apiKeyManager,
      baseUrl: 'https://x',
      stateDir: tmpDir,
      providerOverrides: { azure: factory },
    });
    expect(factory).not.toHaveBeenCalled();
    svc.getProvider();
    expect(factory).toHaveBeenCalledTimes(1);
    svc.getProvider();
    expect(factory).toHaveBeenCalledTimes(1);  // cached
  });

  test('unknown provider name surfaces NOT_INITIALIZED', () => {
    const svc = new EmbeddingService({
      config: { provider: 'cohere' },
      apiKeyManager: { getEmbeddingApiKey: () => 'k' },
      baseUrl: 'https://x',
      stateDir: tmpDir,
    });
    expect(() => svc.getProvider()).toThrow(/cohere/);
  });

  test('updateConfig hot-swaps the provider', async () => {
    const { svc, stubProvider } = makeService({ tmpDir, providerName: 'azure' });
    expect(svc.getProvider()).toBe(stubProvider);
    const local = new StubProvider({ name: 'local', dimensions: 8, fingerprint: 'local:foo' });
    await svc.updateConfig({ provider: 'local' });
    // After updateConfig we need a new override for the new provider name.
    svc._providerOverrides.local = () => local;
    expect(svc.getProvider()).toBe(local);
    expect(svc.getProvider()).not.toBe(stubProvider);
  });
});

describe('EmbeddingService — embed surfaces + telemetry', () => {
  let tmpDir;
  beforeEach(async () => { tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'loxia-emb-svc-')); });
  afterEach(async () => { await fsp.rm(tmpDir, { recursive: true, force: true }); });

  test('embedQuery increments telemetry', async () => {
    const { svc } = makeService({ tmpDir });
    expect(svc.getTelemetry().callsTotal).toBe(0);
    await svc.embedQuery('hello world');
    const t = svc.getTelemetry();
    expect(t.callsTotal).toBe(1);
    expect(t.tokensTotal).toBeGreaterThan(0);
    expect(t.today.calls).toBe(1);
    expect(t.lastCallAt).not.toBeNull();
  });

  test('embedDocuments counts batch size in call total', async () => {
    const { svc } = makeService({ tmpDir });
    await svc.embedDocuments(['a', 'b', 'c']);
    expect(svc.getTelemetry().callsTotal).toBe(3);
  });

  test('lastError is captured on failure', async () => {
    const apiKeyManager = { getEmbeddingApiKey: () => 'k' };
    class FailingProvider extends StubProvider {
      async _embed() {
        throw new EmbeddingError(EMBEDDING_ERROR_CODES.RATE_LIMITED, 'too fast', { provider: 'azure', transient: true });
      }
    }
    const svc = new EmbeddingService({
      config: { provider: 'azure' },
      apiKeyManager,
      baseUrl: 'https://x',
      stateDir: tmpDir,
      providerOverrides: { azure: () => new FailingProvider() },
    });
    await expect(svc.embedQuery('hi')).rejects.toMatchObject({ code: EMBEDDING_ERROR_CODES.RATE_LIMITED });
    const t = svc.getTelemetry();
    expect(t.lastError).toMatchObject({ code: 'RATE_LIMITED' });
  });
});

describe('EmbeddingService — budget enforcement', () => {
  let tmpDir;
  beforeEach(async () => { tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'loxia-emb-svc-')); });
  afterEach(async () => { await fsp.rm(tmpDir, { recursive: true, force: true }); });

  test('exceeding daily token limit (cloud) throws RATE_LIMITED', async () => {
    const { svc } = makeService({
      tmpDir,
      providerName: 'azure',
      configExtra: { budget: { dailyTokenLimit: 4 } }, // ~16 chars total
    });
    // First call uses ~estimateTokens('hello world')=3 tokens. Allowed.
    await svc.embedQuery('hello world');
    // Now should still allow the next small call but block once we cross 4.
    await expect(svc.embedQuery('hello world')).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.RATE_LIMITED,
    });
  });

  test('local provider is exempt from the cloud token cap', async () => {
    const { svc } = makeService({
      tmpDir,
      providerName: 'local',
      configExtra: { budget: { dailyTokenLimit: 1 } }, // trivially exceeded
    });
    // Many calls; none should hit the budget.
    for (let i = 0; i < 5; i++) {
      // eslint-disable-next-line no-await-in-loop
      await svc.embedQuery('a longer string that would exceed 1 token easily');
    }
    expect(svc.getTelemetry().callsTotal).toBe(5);
  });

  test('dailyTokenLimit=0 disables the cap entirely', async () => {
    const { svc } = makeService({
      tmpDir,
      providerName: 'azure',
      configExtra: { budget: { dailyTokenLimit: 0 } },
    });
    for (let i = 0; i < 3; i++) {
      // eslint-disable-next-line no-await-in-loop
      await svc.embedQuery('hello world');
    }
    expect(svc.getTelemetry().callsTotal).toBe(3);
  });
});

describe('EmbeddingService — VectorStore per-scope', () => {
  let tmpDir;
  beforeEach(async () => { tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'loxia-emb-svc-')); });
  afterEach(async () => { await fsp.rm(tmpDir, { recursive: true, force: true }); });

  test('agent-scoped store path = embeddings/agents/<id>/<surface>.vec.json', async () => {
    const { svc } = makeService({ tmpDir });
    const store = await svc.getStore({ agentId: 'a1', surface: 'memory' });
    expect(store._filePath).toBe(path.join(tmpDir, 'embeddings', 'agents', 'a1', 'memory.vec.json'));
  });

  test('global-scoped store path = embeddings/global/<surface>.vec.json', async () => {
    const { svc } = makeService({ tmpDir });
    const store = await svc.getStore({ surface: 'composio' });
    expect(store._filePath).toBe(path.join(tmpDir, 'embeddings', 'global', 'composio.vec.json'));
  });

  test('getStore is get-or-create: second call returns same instance', async () => {
    const { svc } = makeService({ tmpDir });
    const a = await svc.getStore({ agentId: 'a1', surface: 'memory' });
    const b = await svc.getStore({ agentId: 'a1', surface: 'memory' });
    expect(a).toBe(b);
  });

  test('different scopes return different stores', async () => {
    const { svc } = makeService({ tmpDir });
    const a = await svc.getStore({ agentId: 'a1', surface: 'memory' });
    const b = await svc.getStore({ agentId: 'a2', surface: 'memory' });
    const c = await svc.getStore({ surface: 'composio' });
    expect(a).not.toBe(b);
    expect(a).not.toBe(c);
    expect(b).not.toBe(c);
  });

  test('store carries provider fingerprint + dimensions', async () => {
    const { svc, stubProvider } = makeService({ tmpDir, dimensions: 8 });
    const store = await svc.getStore({ agentId: 'a1', surface: 'memory' });
    const s = await store.stats();
    expect(s.dimensions).toBe(8);
    expect(s.modelFingerprint).toBe(stubProvider.getModelFingerprint());
  });

  test('updateConfig flushes the store map (new provider = re-load)', async () => {
    const { svc } = makeService({ tmpDir, providerName: 'azure' });
    const before = await svc.getStore({ agentId: 'a1', surface: 'memory' });
    await svc.updateConfig({ provider: 'local' });
    svc._providerOverrides.local = () => new StubProvider({ name: 'local', dimensions: 8, fingerprint: 'local:swap' });
    const after = await svc.getStore({ agentId: 'a1', surface: 'memory' });
    expect(after).not.toBe(before);
    expect((await after.stats()).modelFingerprint).toBe('local:swap');
  });

  test('scope missing surface throws', async () => {
    const { svc } = makeService({ tmpDir });
    await expect(svc.getStore({ agentId: 'a1' })).rejects.toThrow(/surface/);
    await expect(svc.getStore({})).rejects.toThrow(/surface/);
  });
});

describe('EmbeddingService — warmup', () => {
  let tmpDir;
  beforeEach(async () => { tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'loxia-emb-svc-')); });
  afterEach(async () => { await fsp.rm(tmpDir, { recursive: true, force: true }); });

  test('warmup calls provider.warmup() when available', async () => {
    class LocalLike extends StubProvider {
      constructor() { super({ name: 'local' }); this.warmupCalled = 0; }
      async warmup() { this.warmupCalled++; }
    }
    const provider = new LocalLike();
    const svc = new EmbeddingService({
      config: { provider: 'local' },
      apiKeyManager: { getEmbeddingApiKey: () => null },
      baseUrl: 'https://x',
      stateDir: tmpDir,
      providerOverrides: { local: () => provider },
    });
    await svc.warmup();
    expect(provider.warmupCalled).toBe(1);
  });

  test('warmup is a no-op when disabled', async () => {
    const svc = new EmbeddingService({
      apiKeyManager: { getEmbeddingApiKey: () => null },
      baseUrl: 'https://x',
      stateDir: tmpDir,
    });
    await expect(svc.warmup()).resolves.toBeUndefined();
  });
});

describe('EmbeddingService — config persistence (Settings UI path)', () => {
  let tmpDir;
  beforeEach(async () => { tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'loxia-emb-cfg-')); });
  afterEach(async () => { await fsp.rm(tmpDir, { recursive: true, force: true }); });

  function makeSvc(extra = {}) {
    return new EmbeddingService({
      apiKeyManager: { getEmbeddingApiKey: () => null },
      baseUrl: 'https://x',
      stateDir: tmpDir,
      ...extra,
    });
  }

  test('updateConfig({persist:true}) writes to disk; loadPersistedConfig reads it back', async () => {
    const first = makeSvc();
    await first.updateConfig({ provider: 'azure', budget: { dailyTokenLimit: 500 } }, { persist: true });
    expect(first.config.provider).toBe('azure');

    const second = makeSvc();
    expect(second.config.provider).toBe('disabled');     // default before load
    const loaded = await second.loadPersistedConfig();
    expect(loaded).toBe(true);
    expect(second.config.provider).toBe('azure');
    expect(second.config.budget.dailyTokenLimit).toBe(500);
  });

  test('updateConfig({persist:false}) does NOT touch the file', async () => {
    const svc = makeSvc();
    await svc.updateConfig({ provider: 'local' });           // default persist=false

    const second = makeSvc();
    const loaded = await second.loadPersistedConfig();
    expect(loaded).toBe(false);                              // nothing on disk
    expect(second.config.provider).toBe('disabled');
  });

  test('loadPersistedConfig is graceful when the file is missing', async () => {
    const svc = makeSvc();
    const loaded = await svc.loadPersistedConfig();
    expect(loaded).toBe(false);
    expect(svc.config.provider).toBe('disabled');
  });

  test('loadPersistedConfig is graceful with corrupt JSON', async () => {
    const file = path.join(tmpDir, 'embeddings', 'embeddings-config.json');
    await fsp.mkdir(path.dirname(file), { recursive: true });
    await fsp.writeFile(file, 'not json{{');
    const svc = makeSvc();
    const loaded = await svc.loadPersistedConfig();
    expect(loaded).toBe(false);                              // ignored
    expect(svc.config.provider).toBe('disabled');            // safe default
  });

  test('persist uses atomic .tmp + rename (no partial file on disk after success)', async () => {
    const svc = makeSvc();
    await svc.updateConfig({ provider: 'azure' }, { persist: true });
    const dir = path.join(tmpDir, 'embeddings');
    const entries = await fsp.readdir(dir);
    expect(entries).toContain('embeddings-config.json');
    expect(entries.filter(e => e.endsWith('.tmp'))).toEqual([]);
  });

  test('invalid config does NOT persist (validation fails first)', async () => {
    const svc = makeSvc();
    const result = await svc.updateConfig({ provider: 'cohere' }, { persist: true });
    expect(result.ok).toBe(false);
    const file = path.join(tmpDir, 'embeddings', 'embeddings-config.json');
    await expect(fsp.access(file)).rejects.toMatchObject({ code: 'ENOENT' });
  });
});
