/**
 * MemoryIndexer tests.
 *
 * Strategy: build a small EmbeddingService against the real VectorStore
 * (in temp dir) with a stub provider that produces deterministic vectors
 * based on text contents. Lets us exercise the FULL indexer path
 * (embed → upsert → query → hybrid fusion) without any HTTP or ONNX.
 *
 * Pins:
 *   - isEnabled reflects the underlying service
 *   - indexOne / deleteOne / indexMany / backfill / search round-trip
 *   - Failures from the underlying embed call are swallowed with a warn
 *     (best-effort policy — memory writes never break)
 *   - memoryToText packing order: title → description → content
 *   - Empty / invalid memories are skipped (not failed)
 *   - hybrid:true is the default and surfaces substring-only matches
 */

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

/**
 * Stub provider that produces deterministic, recognisable vectors so we
 * can assert ordering by content. Each text gets a vector dominated by
 * a "fingerprint" position derived from the text — different texts get
 * different fingerprints so the inner-product ordering matches text
 * identity.
 */
class StubProvider extends EmbeddingProvider {
  constructor(dimensions = 8) {
    super();
    this._dimensions = dimensions;
    this.calls = [];
  }
  getInfo() {
    return {
      name: 'azure',
      modelId: 'stub-mem',
      dimensions: this._dimensions,
      maxBatchSize: 16,
      maxTokensPerText: 1000,
      distinguishesQueryAndDocument: false,
      isLocal: false,
    };
  }
  getModelFingerprint() { return 'stub-mem:v1'; }
  async _embed(texts, kind) {
    this.calls.push({ texts: [...texts], kind });
    return texts.map(t => {
      // Lay one nonzero element per text — position derived from the
      // alphabetic content. Mixed-content texts get a unique fingerprint.
      const idx = (t.charCodeAt(0) || 0) % this._dimensions;
      const vec = Array(this._dimensions).fill(0);
      vec[idx] = 1;
      // Add a small tail so identical first-chars still differ.
      vec[(idx + 1) % this._dimensions] = (t.length % 7) * 0.1;
      return vec;
    });
  }
}

function makeIndexer({ tmpDir, enabled = true } = {}) {
  const apiKeyManager = { getEmbeddingApiKey: jest.fn(() => 'stub-key') };
  if (!enabled) {
    const svc = new EmbeddingService({
      apiKeyManager,
      baseUrl: 'https://x',
      stateDir: tmpDir,
    });
    return new MemoryIndexer({ embeddingService: svc });
  }
  const provider = new StubProvider();
  const svc = new EmbeddingService({
    config: { provider: 'azure' },
    apiKeyManager,
    baseUrl: 'https://x',
    stateDir: tmpDir,
    providerOverrides: { azure: () => provider },
  });
  return { indexer: new MemoryIndexer({ embeddingService: svc }), svc, provider };
}

describe('memoryToText', () => {
  test('packs title → description → content', () => {
    const t = memoryToText({ title: 'T', description: 'D', content: 'C' });
    expect(t).toBe('T\n\nD\n\nC');
  });

  test('drops empty fields (no double newlines from missing pieces)', () => {
    expect(memoryToText({ title: 'T', content: 'C' })).toBe('T\n\nC');
    expect(memoryToText({ content: 'C' })).toBe('C');
    expect(memoryToText({})).toBe('');
    expect(memoryToText(null)).toBe('');
  });
});

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

  test('isEnabled is false when service is disabled', () => {
    const idx = makeIndexer({ tmpDir, enabled: false });
    expect(idx.isEnabled).toBe(false);
  });

  test('indexOne returns {indexed:false, reason:"disabled"}', async () => {
    const idx = makeIndexer({ tmpDir, enabled: false });
    const r = await idx.indexOne('a1', { id: 'm1', title: 'hi' });
    expect(r).toEqual({ indexed: false, reason: 'disabled' });
  });

  test('search returns {enabled:false, hits:[]}', async () => {
    const idx = makeIndexer({ tmpDir, enabled: false });
    const r = await idx.search('a1', 'hello');
    expect(r).toEqual({ enabled: false, hits: [] });
  });

  test('backfill returns {ok:false, reason:"disabled"}', async () => {
    const idx = makeIndexer({ tmpDir, enabled: false });
    const r = await idx.backfill('a1', [{ id: 'm', title: 't', content: 'c' }]);
    expect(r).toEqual({ ok: false, reason: 'disabled' });
  });
});

describe('MemoryIndexer — round-trip', () => {
  let tmpDir, indexer, svc, provider;
  beforeEach(async () => {
    tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'loxia-memidx-'));
    ({ indexer, svc, provider } = makeIndexer({ tmpDir }));
  });
  afterEach(async () => { await fsp.rm(tmpDir, { recursive: true, force: true }); });

  test('indexOne + search: the inserted memory becomes findable', async () => {
    await indexer.indexOne('a1', {
      id: 'm1', title: 'Telegram dedup', content: 'message floods', createdAt: '2026-05-25T12:00:00Z',
    });
    const r = await indexer.search('a1', 'Telegram dedup');
    expect(r.enabled).toBe(true);
    expect(r.hits.some(h => h.id === 'm1')).toBe(true);
  });

  test('indexOne with empty memory returns reason:"empty-text"', async () => {
    const r = await indexer.indexOne('a1', { id: 'm-empty' });
    expect(r).toEqual({ indexed: false, reason: 'empty-text' });
  });

  test('indexOne with invalid input returns reason:"invalid-input"', async () => {
    expect(await indexer.indexOne(null, { id: 'm' })).toMatchObject({ reason: 'invalid-input' });
    expect(await indexer.indexOne('a1', null)).toMatchObject({ reason: 'invalid-input' });
    expect(await indexer.indexOne('a1', {})).toMatchObject({ reason: 'invalid-input' });
  });

  test('deleteOne removes the memory from the index', async () => {
    await indexer.indexOne('a1', { id: 'm1', title: 'first', content: 'one' });
    await indexer.indexOne('a1', { id: 'm2', title: 'second', content: 'two' });
    const before = await indexer.search('a1', 'first');
    expect(before.hits.some(h => h.id === 'm1')).toBe(true);
    await indexer.deleteOne('a1', 'm1');
    const after = await indexer.search('a1', 'first');
    expect(after.hits.some(h => h.id === 'm1')).toBe(false);
  });

  test('indexMany batches memories and counts skipped', async () => {
    const memories = [
      { id: 'm1', title: 'alpha', content: 'one' },
      { id: 'm2', title: 'beta',  content: 'two' },
      { id: 'm3' },                                  // no text → skip
      { title: 'no id' },                            // no id  → skip
    ];
    const result = await indexer.indexMany('a1', memories);
    expect(result.indexed).toBe(2);
    expect(result.skipped).toBe(2);
    expect(result.failed).toBe(0);
  });

  test('indexMany sends a single batched embed call', async () => {
    const memories = Array.from({ length: 5 }, (_, i) => ({
      id: `m${i}`, title: `t${i}`, content: `c${i}`,
    }));
    await indexer.indexMany('a1', memories);
    expect(provider.calls).toHaveLength(1);   // one batched call
    expect(provider.calls[0].texts).toHaveLength(5);
  });

  test('backfill replaces the entire index', async () => {
    await indexer.indexOne('a1', { id: 'stale', title: 'old', content: 'old' });
    const replacement = [
      { id: 'new1', title: 'fresh', content: 'one' },
      { id: 'new2', title: 'newer', content: 'two' },
    ];
    const result = await indexer.backfill('a1', replacement);
    expect(result.ok).toBe(true);
    expect(result.indexed).toBe(2);

    const search = await indexer.search('a1', 'fresh');
    expect(search.hits.some(h => h.id === 'new1')).toBe(true);
    expect(search.hits.some(h => h.id === 'stale')).toBe(false);
  });

  test('per-agent isolation: search on a1 does NOT return a2 memories', async () => {
    await indexer.indexOne('a1', { id: 'm1', title: 'agent1-thing', content: 'x' });
    await indexer.indexOne('a2', { id: 'm1', title: 'agent2-thing', content: 'y' });
    const r1 = await indexer.search('a1', 'thing');
    expect(r1.hits.length).toBeGreaterThan(0);
    expect(r1.hits.every(h => h.metadata?.title === 'agent1-thing')).toBe(true);
  });

  test('hybrid search by default surfaces substring-only matches', async () => {
    // Memory whose semantic vector won't match (stub generates by first char)
    // but whose substring DOES match the query.
    await indexer.indexOne('a1', {
      id: 'm-sub',
      title: 'arbitrary title',
      content: 'this body mentions UNIQUE_TOKEN_X explicitly',
    });
    const r = await indexer.search('a1', 'UNIQUE_TOKEN_X');
    expect(r.hits.some(h => h.id === 'm-sub')).toBe(true);
  });

  test('recency boost ranks fresh memories above stale ones on tie', async () => {
    // Two memories with the same text → identical cosine. The fresh
    // one's createdAt should make it rank first by default.
    await indexer.indexOne('a1', {
      id: 'fresh', title: 'shared', content: 'shared body',
      createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
    });
    await indexer.indexOne('a1', {
      id: 'stale', title: 'shared', content: 'shared body',
      createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000).toISOString(),
    });
    const r = await indexer.search('a1', 'shared body');
    expect(r.hits.length).toBeGreaterThanOrEqual(2);
    const freshIdx = r.hits.findIndex(h => h.id === 'fresh');
    const staleIdx = r.hits.findIndex(h => h.id === 'stale');
    expect(freshIdx).toBeLessThan(staleIdx);
  });

  test('boost:false disables recency adjustment (raw cosine ranking)', async () => {
    await indexer.indexOne('a1', { id: 'fresh', title: 'foo', content: 'foo' });
    await indexer.indexOne('a1', { id: 'stale', title: 'foo', content: 'foo',
      createdAt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString() });
    // With boost off, identical text → effectively identical scores; the
    // order may be insertion-dependent but neither should be massively
    // pulled away by recency.
    const r = await indexer.search('a1', 'foo', { boost: false });
    const scores = r.hits.map(h => h.score);
    expect(Math.max(...scores) - Math.min(...scores)).toBeLessThan(0.05);
  });

  test('hybrid:false disables substring fusion', async () => {
    await indexer.indexOne('a1', {
      id: 'm-sub',
      title: 'arbitrary',
      content: 'this body mentions UNIQUE_TOKEN_X',
    });
    // Pure-semantic with stub provider and a wildly different query —
    // we get nothing useful, but the call shouldn't throw.
    const r = await indexer.search('a1', 'UNIQUE_TOKEN_X', { hybrid: false });
    expect(r.enabled).toBe(true);
    expect(Array.isArray(r.hits)).toBe(true);
  });
});

describe('MemoryIndexer — team-pool (Phase 6c)', () => {
  let tmpDir, indexer, svc;
  beforeEach(async () => {
    tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'loxia-memidx-team-'));
    ({ indexer, svc } = makeIndexer({ tmpDir }));
  });
  afterEach(async () => { await fsp.rm(tmpDir, { recursive: true, force: true }); });

  test('publishToTeam stores under the team scope path', async () => {
    await indexer.publishToTeam('team-A', {
      id: 'mem-1', title: 'Useful note', content: 'shared knowledge',
    }, 'agent-publisher');
    const store = await svc.getStore({ teamId: 'team-A', surface: 'memory' });
    expect(store._filePath).toMatch(/[\\/]teams[\\/]team-A[\\/]memory\.vec\.json$/);
    expect((await store.stats()).count).toBe(1);
  });

  test('publishToTeam records publishedBy + publishedAt in metadata', async () => {
    await indexer.publishToTeam('team-A', {
      id: 'mem-1', title: 't', content: 'c',
    }, 'agent-publisher');
    const store = await svc.getStore({ teamId: 'team-A', surface: 'memory' });
    const row = store._rows[0];
    expect(row.metadata.publishedBy).toBe('agent-publisher');
    expect(row.metadata.publishedAt).toBeTruthy();
    expect(typeof row.metadata.publishedAt).toBe('string');
  });

  test('disabled service: publishToTeam returns {indexed:false, reason:"disabled"}', async () => {
    // makeIndexer({enabled:false}) returns the bare indexer (no destructure).
    const disabled = makeIndexer({ tmpDir, enabled: false });
    const r = await disabled.publishToTeam('team-A', { id: 'mem-1', title: 't', content: 'c' }, 'agent');
    expect(r).toEqual({ indexed: false, reason: 'disabled' });
  });

  test('invalid input (missing teamId or memory.id) → invalid-input', async () => {
    const r1 = await indexer.publishToTeam(null, { id: 'm', title: 't', content: 'c' }, 'a');
    const r2 = await indexer.publishToTeam('team-A', {}, 'a');
    expect(r1.reason).toBe('invalid-input');
    expect(r2.reason).toBe('invalid-input');
  });

  test('searchTeam returns hits from the team pool only (per-team isolation)', async () => {
    await indexer.publishToTeam('team-A', { id: 'a1', title: 'alpha', content: 'foo' }, 'agent');
    await indexer.publishToTeam('team-B', { id: 'b1', title: 'beta',  content: 'bar' }, 'agent');
    const rA = await indexer.searchTeam('team-A', 'alpha');
    const rB = await indexer.searchTeam('team-B', 'beta');
    expect(rA.hits.some(h => h.id === 'a1')).toBe(true);
    expect(rA.hits.some(h => h.id === 'b1')).toBe(false);
    expect(rB.hits.some(h => h.id === 'b1')).toBe(true);
    expect(rB.hits.some(h => h.id === 'a1')).toBe(false);
  });

  test('searchTeam respects topK + hybrid + boost flags', async () => {
    for (let i = 0; i < 5; i++) {
      await indexer.publishToTeam('team-A', { id: `m${i}`, title: `t${i}`, content: 'shared content' }, 'agent');
    }
    const r = await indexer.searchTeam('team-A', 'shared content', { topK: 2 });
    expect(r.hits.length).toBeLessThanOrEqual(2);
  });

  test('unpublishFromTeam removes the entry from the team pool', async () => {
    await indexer.publishToTeam('team-A', { id: 'm1', title: 't', content: 'c' }, 'agent');
    expect((await indexer.teamStats('team-A')).count).toBe(1);
    await indexer.unpublishFromTeam('team-A', 'm1');
    expect((await indexer.teamStats('team-A')).count).toBe(0);
  });

  test('agent\'s own memory and team pool are SEPARATE stores', async () => {
    await indexer.indexOne('agent-1', { id: 'mine', title: 'private', content: 'p' });
    await indexer.publishToTeam('team-A', { id: 'shared', title: 'team', content: 'shared' }, 'agent-1');

    const own = await indexer.search('agent-1', 'private');
    const team = await indexer.searchTeam('team-A', 'team');
    expect(own.hits.some(h => h.id === 'mine')).toBe(true);
    expect(own.hits.some(h => h.id === 'shared')).toBe(false);     // not in personal pool
    expect(team.hits.some(h => h.id === 'shared')).toBe(true);
    expect(team.hits.some(h => h.id === 'mine')).toBe(false);     // not in team pool
  });

  test('teamStats reports count, dimensions, and fingerprint', async () => {
    await indexer.publishToTeam('team-A', { id: 'a1', title: 't', content: 'c' }, 'agent');
    const s = await indexer.teamStats('team-A');
    expect(s.enabled).toBe(true);
    expect(s.count).toBe(1);
    expect(s.dimensions).toBe(8);                       // stub dim
    expect(s.modelFingerprint).toBe('stub-mem:v1');
  });
});

describe('MemoryIndexer — failure resilience', () => {
  let tmpDir;
  beforeEach(async () => { tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'loxia-memidx-')); });
  afterEach(async () => { await fsp.rm(tmpDir, { recursive: true, force: true }); });

  test('indexOne swallows embed failure (does not throw)', async () => {
    const apiKeyManager = { getEmbeddingApiKey: () => 'k' };
    class FailingProvider extends StubProvider {
      async _embed() { throw new Error('upstream down'); }
    }
    const svc = new EmbeddingService({
      config: { provider: 'azure' },
      apiKeyManager, baseUrl: 'https://x', stateDir: tmpDir,
      providerOverrides: { azure: () => new FailingProvider() },
    });
    const idx = new MemoryIndexer({ embeddingService: svc });
    const result = await idx.indexOne('a1', { id: 'm', title: 't', content: 'c' });
    expect(result.indexed).toBe(false);
    expect(result.reason).toBe('error');
    expect(result.error).toMatch(/upstream/);
  });

  test('indexMany returns failed counts on embed failure', async () => {
    const apiKeyManager = { getEmbeddingApiKey: () => 'k' };
    class FailingProvider extends StubProvider {
      async _embed() { throw new Error('rate-limited'); }
    }
    const svc = new EmbeddingService({
      config: { provider: 'azure' },
      apiKeyManager, baseUrl: 'https://x', stateDir: tmpDir,
      providerOverrides: { azure: () => new FailingProvider() },
    });
    const idx = new MemoryIndexer({ embeddingService: svc });
    const result = await idx.indexMany('a1', [
      { id: 'm1', title: 'a', content: 'x' },
      { id: 'm2', title: 'b', content: 'y' },
    ]);
    expect(result.indexed).toBe(0);
    expect(result.failed).toBe(2);
    expect(result.error).toMatch(/rate-limited/);
  });

  test('search returns enabled:true but empty hits on failure', async () => {
    const apiKeyManager = { getEmbeddingApiKey: () => 'k' };
    class FailingProvider extends StubProvider {
      async _embed() { throw new Error('boom'); }
    }
    const svc = new EmbeddingService({
      config: { provider: 'azure' },
      apiKeyManager, baseUrl: 'https://x', stateDir: tmpDir,
      providerOverrides: { azure: () => new FailingProvider() },
    });
    const idx = new MemoryIndexer({ embeddingService: svc });
    const r = await idx.search('a1', 'hello');
    expect(r.enabled).toBe(true);
    expect(r.hits).toEqual([]);
    expect(r.error).toMatch(/boom/);
  });
});
