// Tests for src/knowledge-graph.js — Knowledge Graph module

jest.mock('../src/constants');
jest.mock('../src/ollama', () => ({
  streamChat: jest.fn().mockResolvedValue({ response: '{"entities":[],"edges":[]}' }),
  embed: jest.fn().mockResolvedValue([]),
}));

const fs = require('fs');
const path = require('path');
const { PRE_DIR } = require('../src/constants');

// Clear graph before requiring module
const GRAPH_FILE = path.join(PRE_DIR, 'knowledge-graph.json');
if (fs.existsSync(GRAPH_FILE)) fs.unlinkSync(GRAPH_FILE);

const kg = require('../src/knowledge-graph');

describe('knowledge-graph', () => {
  beforeEach(() => {
    // Reset graph state between tests
    const graph = kg.loadGraph();
    graph.entities = {};
    graph.edges = [];
    kg.saveGraph();
  });

  describe('entity operations', () => {
    test('addEntity creates a new entity', () => {
      const entity = kg.addEntity('PRE', 'project');
      expect(entity.id).toBe('pre');
      expect(entity.name).toBe('PRE');
      expect(entity.type).toBe('project');
      expect(entity.aliases).toEqual([]);
    });

    test('addEntity merges on duplicate', () => {
      kg.addEntity('PRE', 'project');
      const merged = kg.addEntity('PRE', 'project', { version: '2.0' });
      expect(merged.id).toBe('pre');
      expect(merged.properties.version).toBe('2.0');
    });

    test('addEntity merges alias when name differs but id matches', () => {
      kg.addEntity('PRE Project', 'project');
      // Same entity, different name variant
      const merged = kg.addEntity('PRE Project', 'project', { lang: 'js' });
      expect(merged.properties.lang).toBe('js');
    });

    test('addEntity records source provenance', () => {
      const entity = kg.addEntity('Ollama', 'system', {}, 'memory:test.md');
      expect(entity.sources).toEqual(['memory:test.md']);
    });

    test('addEntity defaults to concept for unknown types', () => {
      const entity = kg.addEntity('Unknown', 'banana');
      expect(entity.type).toBe('concept');
    });

    test('removeEntity deletes entity and its edges', () => {
      kg.addEntity('A', 'concept');
      kg.addEntity('B', 'concept');
      kg.addEdge('A', 'B', 'related_to');
      expect(kg.loadGraph().edges.length).toBe(1);

      const result = kg.removeEntity('A');
      expect(result.success).toBe(true);
      expect(kg.getEntity('A')).toBeNull();
      expect(kg.loadGraph().edges.length).toBe(0);
    });

    test('removeEntity returns error for missing entity', () => {
      const result = kg.removeEntity('Nonexistent');
      expect(result.error).toBeDefined();
    });

    test('getEntity retrieves by name', () => {
      kg.addEntity('TinyGPU', 'tool');
      const entity = kg.getEntity('TinyGPU');
      expect(entity).not.toBeNull();
      expect(entity.name).toBe('TinyGPU');
    });

    test('listEntities returns all entities', () => {
      kg.addEntity('A', 'person');
      kg.addEntity('B', 'project');
      kg.addEntity('C', 'person');
      expect(kg.listEntities().length).toBe(3);
      expect(kg.listEntities('person').length).toBe(2);
      expect(kg.listEntities('project').length).toBe(1);
    });

    test('findEntity searches by name and aliases', () => {
      kg.addEntity('Christopher Bradford', 'person');
      kg.addEntity('PRE Engine', 'project');

      expect(kg.findEntity('chris').length).toBe(1);
      expect(kg.findEntity('pre').length).toBe(1);
      expect(kg.findEntity('nonexistent').length).toBe(0);
    });
  });

  describe('edge operations', () => {
    test('addEdge connects entities', () => {
      kg.addEntity('Chris', 'person');
      kg.addEntity('PRE', 'project');
      const result = kg.addEdge('Chris', 'PRE', 'works_on');
      expect(result.success).toBe(true);
      expect(kg.loadGraph().edges.length).toBe(1);
    });

    test('addEdge rejects missing entities', () => {
      kg.addEntity('Chris', 'person');
      const result = kg.addEdge('Chris', 'Nonexistent', 'uses');
      expect(result.error).toBeDefined();
    });

    test('addEdge prevents duplicates', () => {
      kg.addEntity('A', 'concept');
      kg.addEntity('B', 'concept');
      kg.addEdge('A', 'B', 'related_to');
      const result = kg.addEdge('A', 'B', 'related_to');
      expect(result.exists).toBe(true);
      expect(kg.loadGraph().edges.length).toBe(1);
    });

    test('addEdge defaults unknown relations to related_to', () => {
      kg.addEntity('A', 'concept');
      kg.addEntity('B', 'concept');
      kg.addEdge('A', 'B', 'banana_relation');
      const edge = kg.loadGraph().edges[0];
      expect(edge.relation).toBe('related_to');
    });

    test('removeEdge deletes matching edge', () => {
      kg.addEntity('A', 'concept');
      kg.addEntity('B', 'concept');
      kg.addEdge('A', 'B', 'uses');
      const result = kg.removeEdge('A', 'B', 'uses');
      expect(result.success).toBe(true);
      expect(kg.loadGraph().edges.length).toBe(0);
    });
  });

  describe('traversal', () => {
    beforeEach(() => {
      kg.addEntity('Chris', 'person');
      kg.addEntity('PRE', 'project');
      kg.addEntity('Ollama', 'system');
      kg.addEntity('TinyGPU', 'tool');
      kg.addEdge('Chris', 'PRE', 'works_on');
      kg.addEdge('PRE', 'Ollama', 'uses');
      kg.addEdge('PRE', 'TinyGPU', 'uses');
    });

    test('getNeighbors returns connected entities', () => {
      const neighbors = kg.getNeighbors('PRE');
      expect(neighbors.outgoing.length).toBe(2); // Ollama, TinyGPU
      expect(neighbors.incoming.length).toBe(1); // Chris
      expect(neighbors.outgoing[0].relation).toBe('uses');
      expect(neighbors.incoming[0].relation).toBe('works_on');
    });

    test('getNeighbors returns empty for unknown entity', () => {
      const neighbors = kg.getNeighbors('Unknown');
      expect(neighbors.outgoing).toEqual([]);
      expect(neighbors.incoming).toEqual([]);
    });

    test('getSubgraph returns neighborhood within hops', () => {
      const sub = kg.getSubgraph('Chris', 1);
      expect(sub.entities.length).toBe(2); // Chris + PRE
      expect(sub.edges.length).toBe(1);   // Chris→PRE

      const sub2 = kg.getSubgraph('Chris', 2);
      expect(sub2.entities.length).toBe(4); // All entities
      expect(sub2.edges.length).toBe(3);   // All edges
    });

    test('queryGraph finds entities and context', () => {
      const result = kg.queryGraph('PRE');
      expect(result.matches.length).toBe(1);
      expect(result.context).toContain('PRE');
      expect(result.context).toContain('knowledge_graph');
    });

    test('queryGraph returns empty for no matches', () => {
      const result = kg.queryGraph('NonexistentEntity');
      expect(result.matches).toEqual([]);
      expect(result.context).toBe('');
    });
  });

  describe('entityId', () => {
    test('normalizes names to lowercase slug', () => {
      expect(kg.entityId('Christopher Bradford')).toBe('christopher_bradford');
      expect(kg.entityId('PRE-Engine')).toBe('pre_engine');
      expect(kg.entityId('Node.js')).toBe('node_js');
      expect(kg.entityId('  spaces  ')).toBe('spaces');
    });
  });

  describe('stats', () => {
    test('returns correct counts', () => {
      kg.addEntity('A', 'person');
      kg.addEntity('B', 'project');
      kg.addEntity('C', 'tool');
      kg.addEdge('A', 'B', 'works_on');
      kg.addEdge('B', 'C', 'uses');

      const stats = kg.getStats();
      expect(stats.entityCount).toBe(3);
      expect(stats.edgeCount).toBe(2);
      expect(stats.typeCounts.person).toBe(1);
      expect(stats.typeCounts.project).toBe(1);
      expect(stats.typeCounts.tool).toBe(1);
      expect(stats.relationCounts.works_on).toBe(1);
      expect(stats.relationCounts.uses).toBe(1);
    });
  });

  describe('persistence', () => {
    test('graph survives reload', () => {
      kg.addEntity('Persistent', 'concept');
      kg.addEntity('Other', 'concept');
      kg.addEdge('Persistent', 'Other', 'related_to');
      kg.saveGraph();

      // Clear in-memory cache and reload
      const freshData = JSON.parse(fs.readFileSync(GRAPH_FILE, 'utf-8'));
      expect(Object.keys(freshData.entities).length).toBe(2);
      expect(freshData.edges.length).toBe(1);
    });
  });

  describe('extractFromText', () => {
    const { streamChat } = require('../src/ollama');

    test('extracts entities from LLM response', async () => {
      streamChat.mockResolvedValueOnce({
        response: JSON.stringify({
          entities: [
            { name: 'Joby Aviation', type: 'organization' },
            { name: 'Chris', type: 'person' },
          ],
          edges: [
            { source: 'Chris', target: 'Joby Aviation', relation: 'part_of' },
          ],
        }),
      });

      const result = await kg.extractFromText('Chris works at Joby Aviation.');
      expect(result.entities.length).toBe(2);
      expect(result.edges.length).toBe(1);

      // Entities should be added to graph
      expect(kg.getEntity('Joby Aviation')).not.toBeNull();
      expect(kg.getEntity('Chris')).not.toBeNull();
    });

    test('handles empty extraction gracefully', async () => {
      streamChat.mockResolvedValueOnce({
        response: '{"entities":[],"edges":[]}',
      });

      const result = await kg.extractFromText('Just a simple message.');
      expect(result.entities).toEqual([]);
      expect(result.edges).toEqual([]);
    });

    test('skips text shorter than 20 chars', async () => {
      const result = await kg.extractFromText('hi');
      expect(result.entities).toEqual([]);
    });
  });
});
