import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { AggregationIndex } from '../../../src/aggregation/AggregationIndex'
import { MemoryStorage } from '../../../src/storage/storageFactory'
import { NounType } from '../../../src/types/graphTypes'
import type { AggregateDefinition, AggregateGroupState } from '../../../src/types/brainy.types'

describe('AggregationIndex', () => {
  let storage: MemoryStorage
  let index: AggregationIndex

  beforeEach(async () => {
    storage = new MemoryStorage()
    await storage.init()
    index = new AggregationIndex(storage)
    await index.init()
  })

  afterEach(async () => {
    await index.close()
  })

  // ============= Definition Management =============

  describe('defineAggregate', () => {
    it('should register a valid aggregate definition', () => {
      const def: AggregateDefinition = {
        name: 'test_agg',
        source: { type: NounType.Event },
        groupBy: ['category'],
        metrics: { count: { op: 'count' } }
      }

      index.defineAggregate(def)

      expect(index.hasAggregate('test_agg')).toBe(true)
      expect(index.getDefinitions()).toHaveLength(1)
      expect(index.getDefinitions()[0].name).toBe('test_agg')
    })

    it('should reject definition without name', () => {
      expect(() => index.defineAggregate({
        name: '',
        source: { type: NounType.Event },
        groupBy: ['x'],
        metrics: { count: { op: 'count' } }
      })).toThrow('requires a name')
    })

    it('should reject definition without groupBy', () => {
      expect(() => index.defineAggregate({
        name: 'bad',
        source: { type: NounType.Event },
        groupBy: [],
        metrics: { count: { op: 'count' } }
      })).toThrow('at least one groupBy')
    })

    it('should reject definition without metrics', () => {
      expect(() => index.defineAggregate({
        name: 'bad',
        source: { type: NounType.Event },
        groupBy: ['x'],
        metrics: {}
      })).toThrow('at least one metric')
    })

    it('should reject sum metric without field', () => {
      expect(() => index.defineAggregate({
        name: 'bad',
        source: { type: NounType.Event },
        groupBy: ['x'],
        metrics: { total: { op: 'sum' } }
      })).toThrow("requires a 'field'")
    })

    it('should allow count metric without field', () => {
      index.defineAggregate({
        name: 'ok',
        source: { type: NounType.Event },
        groupBy: ['x'],
        metrics: { count: { op: 'count' } }
      })
      expect(index.hasAggregate('ok')).toBe(true)
    })
  })

  describe('removeAggregate', () => {
    it('should remove an existing aggregate', () => {
      index.defineAggregate({
        name: 'temp',
        source: { type: NounType.Event },
        groupBy: ['x'],
        metrics: { count: { op: 'count' } }
      })

      expect(index.hasAggregate('temp')).toBe(true)
      index.removeAggregate('temp')
      expect(index.hasAggregate('temp')).toBe(false)
    })
  })

  // ============= Write-Time Hooks =============

  describe('onEntityAdded', () => {
    const spendingDef: AggregateDefinition = {
      name: 'spending',
      source: { type: NounType.Event, where: { domain: 'financial' } },
      groupBy: ['category'],
      metrics: {
        total: { op: 'sum', field: 'amount' },
        count: { op: 'count' },
        average: { op: 'avg', field: 'amount' },
        highest: { op: 'max', field: 'amount' },
        lowest: { op: 'min', field: 'amount' }
      }
    }

    beforeEach(() => {
      index.defineAggregate(spendingDef)
    })

    it('should accumulate sum/count/avg/min/max for matching entities', () => {
      index.onEntityAdded('e1', {
        type: NounType.Event,
        metadata: { domain: 'financial', category: 'food', amount: 10 }
      })
      index.onEntityAdded('e2', {
        type: NounType.Event,
        metadata: { domain: 'financial', category: 'food', amount: 20 }
      })
      index.onEntityAdded('e3', {
        type: NounType.Event,
        metadata: { domain: 'financial', category: 'food', amount: 30 }
      })

      const results = index.queryAggregate({ name: 'spending' })
      expect(results).toHaveLength(1)
      expect(results[0].groupKey).toEqual({ category: 'food' })
      expect(results[0].metrics.total).toBe(60)
      expect(results[0].metrics.count).toBe(3)
      expect(results[0].metrics.average).toBe(20)
      expect(results[0].metrics.highest).toBe(30)
      expect(results[0].metrics.lowest).toBe(10)
      expect(results[0].count).toBe(3)
    })

    it('should create separate groups for different keys', () => {
      index.onEntityAdded('e1', {
        type: NounType.Event,
        metadata: { domain: 'financial', category: 'food', amount: 10 }
      })
      index.onEntityAdded('e2', {
        type: NounType.Event,
        metadata: { domain: 'financial', category: 'transport', amount: 50 }
      })

      const results = index.queryAggregate({ name: 'spending' })
      expect(results).toHaveLength(2)

      const food = results.find(r => r.groupKey.category === 'food')!
      const transport = results.find(r => r.groupKey.category === 'transport')!
      expect(food.metrics.total).toBe(10)
      expect(transport.metrics.total).toBe(50)
    })

    it('should skip entities that do not match source filter', () => {
      // Wrong type
      index.onEntityAdded('e1', {
        type: NounType.Person,
        metadata: { domain: 'financial', category: 'food', amount: 100 }
      })

      // Missing domain
      index.onEntityAdded('e2', {
        type: NounType.Event,
        metadata: { category: 'food', amount: 100 }
      })

      const results = index.queryAggregate({ name: 'spending' })
      expect(results).toHaveLength(0)
    })

    it('should skip materialized aggregate entities (infinite loop prevention)', () => {
      index.onEntityAdded('agg1', {
        type: NounType.Measurement,
        service: 'brainy:aggregation',
        metadata: { domain: 'financial', category: 'food', amount: 999 }
      })

      const results = index.queryAggregate({ name: 'spending' })
      expect(results).toHaveLength(0)
    })

    it('should skip entities with __aggregate metadata (infinite loop prevention)', () => {
      index.onEntityAdded('agg2', {
        type: NounType.Event,
        metadata: { __aggregate: 'spending', domain: 'financial', category: 'food', amount: 999 }
      })

      const results = index.queryAggregate({ name: 'spending' })
      expect(results).toHaveLength(0)
    })
  })

  describe('onEntityUpdated', () => {
    beforeEach(() => {
      index.defineAggregate({
        name: 'totals',
        source: { type: NounType.Event },
        groupBy: ['category'],
        metrics: {
          total: { op: 'sum', field: 'amount' },
          count: { op: 'count' }
        }
      })
    })

    it('should reverse old contribution and apply new', () => {
      const oldEntity = { type: NounType.Event, metadata: { category: 'food', amount: 10 } }
      const newEntity = { type: NounType.Event, metadata: { category: 'food', amount: 25 } }

      index.onEntityAdded('e1', oldEntity)
      expect(index.queryAggregate({ name: 'totals' })[0].metrics.total).toBe(10)

      index.onEntityUpdated('e1', newEntity, oldEntity)
      const results = index.queryAggregate({ name: 'totals' })
      expect(results[0].metrics.total).toBe(25)
      expect(results[0].metrics.count).toBe(1)
    })

    it('should handle category change (different group keys)', () => {
      const old = { type: NounType.Event, metadata: { category: 'food', amount: 10 } }
      const updated = { type: NounType.Event, metadata: { category: 'transport', amount: 10 } }

      index.onEntityAdded('e1', old)
      index.onEntityUpdated('e1', updated, old)

      const results = index.queryAggregate({ name: 'totals' })
      // Old group should be removed (empty), new group should exist
      expect(results).toHaveLength(1)
      expect(results[0].groupKey.category).toBe('transport')
      expect(results[0].metrics.total).toBe(10)
    })

    it('should handle entity no longer matching source', () => {
      const old = { type: NounType.Event, metadata: { category: 'food', amount: 10 } }
      const updated = { type: NounType.Person, metadata: { category: 'food', amount: 10 } }

      index.onEntityAdded('e1', old)
      expect(index.queryAggregate({ name: 'totals' })).toHaveLength(1)

      index.onEntityUpdated('e1', updated, old)
      expect(index.queryAggregate({ name: 'totals' })).toHaveLength(0)
    })

    it('should handle entity now matching source', () => {
      const old = { type: NounType.Person, metadata: { category: 'food', amount: 10 } }
      const updated = { type: NounType.Event, metadata: { category: 'food', amount: 10 } }

      index.onEntityAdded('e1', old) // Won't match
      expect(index.queryAggregate({ name: 'totals' })).toHaveLength(0)

      index.onEntityUpdated('e1', updated, old)
      expect(index.queryAggregate({ name: 'totals' })).toHaveLength(1)
      expect(index.queryAggregate({ name: 'totals' })[0].metrics.total).toBe(10)
    })
  })

  describe('onEntityDeleted', () => {
    beforeEach(() => {
      index.defineAggregate({
        name: 'totals',
        source: { type: NounType.Event },
        groupBy: ['category'],
        metrics: {
          total: { op: 'sum', field: 'amount' },
          count: { op: 'count' }
        }
      })
    })

    it('should decrement sum and count', () => {
      const entity = { type: NounType.Event, metadata: { category: 'food', amount: 10 } }
      index.onEntityAdded('e1', entity)
      index.onEntityAdded('e2', { type: NounType.Event, metadata: { category: 'food', amount: 20 } })

      index.onEntityDeleted('e1', entity)

      const results = index.queryAggregate({ name: 'totals' })
      expect(results).toHaveLength(1)
      expect(results[0].metrics.total).toBe(20)
      expect(results[0].metrics.count).toBe(1)
    })

    it('should remove group when last entity is deleted', () => {
      const entity = { type: NounType.Event, metadata: { category: 'food', amount: 10 } }
      index.onEntityAdded('e1', entity)
      index.onEntityDeleted('e1', entity)

      const results = index.queryAggregate({ name: 'totals' })
      expect(results).toHaveLength(0)
    })
  })

  // ============= Time Windows =============

  describe('time-windowed groupBy', () => {
    beforeEach(() => {
      index.defineAggregate({
        name: 'monthly',
        source: { type: NounType.Event },
        groupBy: [
          'category',
          { field: 'date', window: 'month' }
        ],
        metrics: {
          total: { op: 'sum', field: 'amount' },
          count: { op: 'count' }
        }
      })
    })

    it('should group by both category and time window', () => {
      const jan = Date.UTC(2024, 0, 15)
      const feb = Date.UTC(2024, 1, 15)

      index.onEntityAdded('e1', {
        type: NounType.Event,
        metadata: { category: 'food', date: jan, amount: 100 }
      })
      index.onEntityAdded('e2', {
        type: NounType.Event,
        metadata: { category: 'food', date: feb, amount: 200 }
      })
      index.onEntityAdded('e3', {
        type: NounType.Event,
        metadata: { category: 'food', date: jan, amount: 50 }
      })

      const results = index.queryAggregate({ name: 'monthly' })
      expect(results).toHaveLength(2)

      const janGroup = results.find(r => r.groupKey.date === '2024-01')!
      const febGroup = results.find(r => r.groupKey.date === '2024-02')!
      expect(janGroup.metrics.total).toBe(150)
      expect(janGroup.metrics.count).toBe(2)
      expect(febGroup.metrics.total).toBe(200)
      expect(febGroup.metrics.count).toBe(1)
    })
  })

  // ============= Query =============

  describe('queryAggregate', () => {
    beforeEach(() => {
      index.defineAggregate({
        name: 'spending',
        source: { type: NounType.Event },
        groupBy: ['category'],
        metrics: {
          total: { op: 'sum', field: 'amount' },
          count: { op: 'count' }
        }
      })

      // Add several categories
      for (let i = 0; i < 5; i++) {
        index.onEntityAdded(`food-${i}`, {
          type: NounType.Event,
          metadata: { category: 'food', amount: 10 * (i + 1) }
        })
      }
      for (let i = 0; i < 3; i++) {
        index.onEntityAdded(`transport-${i}`, {
          type: NounType.Event,
          metadata: { category: 'transport', amount: 20 * (i + 1) }
        })
      }
      index.onEntityAdded('housing-1', {
        type: NounType.Event,
        metadata: { category: 'housing', amount: 1500 }
      })
    })

    it('should throw for unknown aggregate', () => {
      expect(() => index.queryAggregate({ name: 'nonexistent' })).toThrow("not found")
    })

    it('should return all groups by default', () => {
      const results = index.queryAggregate({ name: 'spending' })
      expect(results).toHaveLength(3)
    })

    it('should filter by where clause', () => {
      const results = index.queryAggregate({
        name: 'spending',
        where: { category: 'food' }
      })
      expect(results).toHaveLength(1)
      expect(results[0].groupKey.category).toBe('food')
      expect(results[0].metrics.total).toBe(150) // 10+20+30+40+50
    })

    it('should sort by metric ascending', () => {
      const results = index.queryAggregate({
        name: 'spending',
        orderBy: 'total',
        order: 'asc'
      })
      expect(results[0].metrics.total).toBeLessThanOrEqual(results[1].metrics.total)
    })

    it('should sort by metric descending', () => {
      const results = index.queryAggregate({
        name: 'spending',
        orderBy: 'total',
        order: 'desc'
      })
      expect(results[0].metrics.total).toBe(1500) // housing
    })

    it('should support limit', () => {
      const results = index.queryAggregate({
        name: 'spending',
        orderBy: 'total',
        order: 'desc',
        limit: 2
      })
      expect(results).toHaveLength(2)
    })

    it('should support offset', () => {
      const all = index.queryAggregate({
        name: 'spending',
        orderBy: 'total',
        order: 'desc'
      })
      const page2 = index.queryAggregate({
        name: 'spending',
        orderBy: 'total',
        order: 'desc',
        offset: 1,
        limit: 1
      })
      expect(page2).toHaveLength(1)
      expect(page2[0].groupKey.category).toBe(all[1].groupKey.category)
    })
  })

  // ============= Multiple Overlapping Aggregates =============

  describe('multiple aggregates', () => {
    it('should update multiple aggregates on a single entity add', () => {
      index.defineAggregate({
        name: 'by_category',
        source: { type: NounType.Event },
        groupBy: ['category'],
        metrics: { count: { op: 'count' } }
      })
      index.defineAggregate({
        name: 'by_merchant',
        source: { type: NounType.Event },
        groupBy: ['merchant'],
        metrics: { total: { op: 'sum', field: 'amount' } }
      })

      index.onEntityAdded('e1', {
        type: NounType.Event,
        metadata: { category: 'food', merchant: 'Starbucks', amount: 5 }
      })

      const catResults = index.queryAggregate({ name: 'by_category' })
      const merchResults = index.queryAggregate({ name: 'by_merchant' })

      expect(catResults).toHaveLength(1)
      expect(catResults[0].metrics.count).toBe(1)
      expect(merchResults).toHaveLength(1)
      expect(merchResults[0].metrics.total).toBe(5)
    })
  })

  // ============= Persistence =============

  describe('persistence', () => {
    it('should persist definitions and state across flush/init cycles', async () => {
      index.defineAggregate({
        name: 'persist_test',
        source: { type: NounType.Event },
        groupBy: ['category'],
        metrics: { total: { op: 'sum', field: 'amount' } }
      })

      index.onEntityAdded('e1', {
        type: NounType.Event,
        metadata: { category: 'food', amount: 42 }
      })

      // Flush to persist
      await index.flush()

      // Create new index from same storage
      const index2 = new AggregationIndex(storage)
      await index2.init()

      expect(index2.hasAggregate('persist_test')).toBe(true)
      const results = index2.queryAggregate({ name: 'persist_test' })
      expect(results).toHaveLength(1)
      expect(results[0].metrics.total).toBe(42)

      await index2.close()
    })

    it('should rebuild state when definition hash changes', async () => {
      index.defineAggregate({
        name: 'hash_test',
        source: { type: NounType.Event },
        groupBy: ['category'],
        metrics: { total: { op: 'sum', field: 'amount' } }
      })

      index.onEntityAdded('e1', {
        type: NounType.Event,
        metadata: { category: 'food', amount: 42 }
      })

      await index.flush()

      // Create new index and register a DIFFERENT definition with same name
      const index2 = new AggregationIndex(storage)
      await index2.init()

      // The loaded state should be present (definition unchanged)
      expect(index2.queryAggregate({ name: 'hash_test' })[0].metrics.total).toBe(42)

      // Now define with different metrics — should clear state
      index2.defineAggregate({
        name: 'hash_test',
        source: { type: NounType.Event },
        groupBy: ['category'],
        metrics: { count: { op: 'count' } } // Changed from sum to count
      })

      // State should be fresh (empty — no entities added to the new definition)
      const results = index2.queryAggregate({ name: 'hash_test' })
      expect(results).toHaveLength(0)

      await index2.close()
    })
  })

  // ============= Service Filter =============

  describe('source service filter', () => {
    it('should filter by service', () => {
      index.defineAggregate({
        name: 'svc_test',
        source: { type: NounType.Event, service: 'finance-app' },
        groupBy: ['category'],
        metrics: { count: { op: 'count' } }
      })

      index.onEntityAdded('e1', {
        type: NounType.Event,
        service: 'finance-app',
        metadata: { category: 'food' }
      })
      index.onEntityAdded('e2', {
        type: NounType.Event,
        service: 'other-app',
        metadata: { category: 'food' }
      })

      const results = index.queryAggregate({ name: 'svc_test' })
      expect(results).toHaveLength(1)
      expect(results[0].metrics.count).toBe(1)
    })
  })

  // ============= Scale Test =============

  describe('performance at scale', () => {
    it('should handle 10,000 entities efficiently', () => {
      index.defineAggregate({
        name: 'scale_test',
        source: { type: NounType.Event },
        groupBy: ['category'],
        metrics: {
          total: { op: 'sum', field: 'amount' },
          count: { op: 'count' }
        }
      })

      const categories = ['food', 'transport', 'housing', 'entertainment', 'utilities']
      const start = performance.now()

      for (let i = 0; i < 10_000; i++) {
        index.onEntityAdded(`e${i}`, {
          type: NounType.Event,
          metadata: {
            category: categories[i % categories.length],
            amount: Math.random() * 1000
          }
        })
      }

      const elapsed = performance.now() - start

      // 10K entities should process in under 500ms (O(1) per entity)
      expect(elapsed).toBeLessThan(500)

      const results = index.queryAggregate({ name: 'scale_test' })
      expect(results).toHaveLength(5)

      // Each category should have 2000 entities
      for (const r of results) {
        expect(r.metrics.count).toBe(2000)
      }
    })
  })

  // ============= Percentile + distinctCount (exact, parity with Cortex native) =============

  describe('percentile + distinctCount', () => {
    beforeEach(() => {
      index.defineAggregate({
        name: 'lat',
        source: { type: NounType.Event },
        groupBy: ['svc'],
        metrics: {
          p0: { op: 'percentile', field: 'ms', p: 0 },
          p50: { op: 'percentile', field: 'ms', p: 0.5 },
          p90: { op: 'percentile', field: 'ms', p: 0.9 },
          p100: { op: 'percentile', field: 'ms', p: 1 },
          uniq: { op: 'distinctCount', field: 'ms' }
        }
      })
    })

    const addLat = (id: string, ms: number) =>
      index.onEntityAdded(id, { type: NounType.Event, metadata: { svc: 'api', ms } })

    it('computes exact numpy-linear percentiles (same values as Cortex)', () => {
      for (let v = 1; v <= 10; v++) addLat(`e${v}`, v)
      const [row] = index.queryAggregate({ name: 'lat' })
      expect(row.metrics.p0).toBe(1)
      expect(row.metrics.p50).toBeCloseTo(5.5, 10)
      expect(row.metrics.p90).toBeCloseTo(9.1, 10)
      expect(row.metrics.p100).toBe(10)
      expect(row.metrics.uniq).toBe(10)
    })

    it('stays exact after deletes', () => {
      for (let v = 1; v <= 10; v++) addLat(`e${v}`, v)
      index.onEntityDeleted('e10', { type: NounType.Event, metadata: { svc: 'api', ms: 10 } })
      const [row] = index.queryAggregate({ name: 'lat' })
      expect(row.metrics.p50).toBeCloseTo(5, 10) // median of 1..9
      expect(row.metrics.uniq).toBe(9)
    })

    it('distinctCount counts distinct values, not occurrences', () => {
      addLat('a', 1); addLat('b', 2); addLat('c', 2)
      const [row] = index.queryAggregate({ name: 'lat' })
      expect(row.metrics.uniq).toBe(2) // {1, 2}
    })

    it('percentile + distinctCount survive a persist + reload', async () => {
      for (let v = 1; v <= 10; v++) addLat(`e${v}`, v)
      await index.flush()

      const reloaded = new AggregationIndex(storage)
      await reloaded.init()
      const [row] = reloaded.queryAggregate({ name: 'lat' })
      expect(row.metrics.p50).toBeCloseTo(5.5, 10) // valueCounts round-tripped through storage
      expect(row.metrics.uniq).toBe(10)
      await reloaded.close()
    })
  })
})
