/**
 * @module tests/unit/brainy/migration-deference
 * @description rc.8 no-freeze auto-upgrade hooks — the deference seam that lets a
 * native provider run an ONLINE, background build-new→verify→swap index migration
 * while brainy stays out of the way (never a minutes-long blocking
 * rebuild-on-open / first-query on a large-brain epoch-drift upgrade).
 *
 * Three hooks, locked with the cor team:
 *
 * - Hook 1 (deference): an OPTIONAL sync `isMigrating(): boolean` on each index
 *   provider (metadata / vector / graph). While it returns true, brainy SKIPS its
 *   own rebuild of that index — both in {@link Brainy.rebuildIndexesIfNeeded}
 *   (even under epoch-drift or `size()===0`) and on the large-path first-query
 *   lazy force-rebuild. A NON-migrating sibling still rebuilds when it needs to.
 * - Hook 2: the public `brain.stampBrainFormat()` the provider calls once its
 *   background migration has verified-and-swapped, authoring the shared
 *   `_system/brain-format.json` marker.
 * - Hook 3: the marker module is re-exported at `@soulcraft/brainy/brain-format`
 *   so cor reads the SAME `EXPECTED_INDEX_EPOCH` / `CURRENT_DATA_FORMAT` constants
 *   (single source of truth, no duplicated value).
 *
 * These paths are never exercised by brainy CI (cor registers no provider there),
 * so the deference gates and the public stamp are pinned here with a white-box
 * test-double provider — the same pattern as the cold-graph / handshake tests.
 */

import { describe, it, expect, afterEach, vi } from 'vitest'
import { Brainy } from '../../../src/index.js'
import { NounType } from '../../../src/types/graphTypes.js'
import { createTestConfig } from '../../helpers/test-factory.js'
import { BaseStorage } from '../../../src/storage/baseStorage.js'
import {
  BRAIN_FORMAT_PATH,
  CURRENT_DATA_FORMAT,
  EXPECTED_INDEX_EPOCH
} from '../../../src/storage/brainFormat.js'

const CURRENT_MARKER = { dataFormat: CURRENT_DATA_FORMAT, indexEpoch: EXPECTED_INDEX_EPOCH }

/** The white-box surface this suite drives on a live brain instance. */
interface BrainInternals {
  index: { size(): number; rebuild(...a: unknown[]): Promise<unknown> }
  metadataIndex: { rebuild(...a: unknown[]): Promise<unknown> }
  graphIndex: { size(): number; rebuild(...a: unknown[]): Promise<unknown> }
  _indexEpochStale: boolean
  lazyRebuildCompleted: boolean
  rebuildIndexesIfNeeded(force?: boolean): Promise<void>
  ensureIndexesLoaded(): Promise<void>
  storage: { readRawObject(p: string): Promise<unknown> }
}

const brains: Brainy[] = []

/** Open (and track) a warmed memory brain holding `count` document entities. */
async function makeWarmBrain(count = 2, extraConfig: Record<string, unknown> = {}): Promise<Brainy> {
  const brain = new Brainy(createTestConfig(extraConfig))
  await brain.init()
  brains.push(brain)
  for (let i = 0; i < count; i++) {
    await brain.add({ data: `doc-${i}`, type: NounType.Document, metadata: { k: i } })
  }
  return brain
}

/** Cast a brain to its white-box internals. */
function internalsOf(brain: Brainy): BrainInternals {
  return brain as unknown as BrainInternals
}

/** Force `provider.isMigrating()` to a fixed value (the native provider's deference flag). */
function setMigrating(provider: object, value: boolean): void {
  ;(provider as { isMigrating?: () => boolean }).isMigrating = () => value
}

afterEach(async () => {
  for (const brain of brains.splice(0)) {
    try {
      await brain.close()
    } catch {
      // already closed by the test
    }
  }
  vi.restoreAllMocks()
})

describe('rc.8 no-freeze migration deference (isMigrating / stampBrainFormat / brain-format export)', () => {
  // --- Hook 1: per-index deference in rebuildIndexesIfNeeded ----------------

  it('metadata provider isMigrating(): its rebuild is skipped under epoch-drift; vector + graph siblings still rebuild', async () => {
    const brain = await makeWarmBrain()
    const internals = internalsOf(brain)

    const miSpy = vi.spyOn(internals.metadataIndex, 'rebuild').mockResolvedValue(undefined)
    const idxSpy = vi.spyOn(internals.index, 'rebuild').mockResolvedValue(undefined)
    const giSpy = vi.spyOn(internals.graphIndex, 'rebuild').mockResolvedValue(undefined)

    setMigrating(internals.metadataIndex, true)
    // Epoch-drift would normally force ALL three to rebuild past the warm fast path.
    internals._indexEpochStale = true

    await internals.rebuildIndexesIfNeeded()

    // The migrating provider owns its index — brainy does NOT rebuild it.
    expect(miSpy).toHaveBeenCalledTimes(0)
    // Non-migrating siblings still rebuild under the drift.
    expect(idxSpy).toHaveBeenCalledTimes(1)
    expect(giSpy).toHaveBeenCalledTimes(1)
    // The marker is NOT advanced while a migration is in flight — cor stamps it
    // when its build-new→verify→swap completes, so the stale flag stays set.
    expect(internals._indexEpochStale).toBe(true)
  })

  it('vector provider isMigrating(): its rebuild is skipped under epoch-drift; metadata + graph siblings still rebuild', async () => {
    const brain = await makeWarmBrain()
    const internals = internalsOf(brain)

    const miSpy = vi.spyOn(internals.metadataIndex, 'rebuild').mockResolvedValue(undefined)
    const idxSpy = vi.spyOn(internals.index, 'rebuild').mockResolvedValue(undefined)
    const giSpy = vi.spyOn(internals.graphIndex, 'rebuild').mockResolvedValue(undefined)

    setMigrating(internals.index, true)
    internals._indexEpochStale = true

    await internals.rebuildIndexesIfNeeded()

    expect(idxSpy).toHaveBeenCalledTimes(0)
    expect(miSpy).toHaveBeenCalledTimes(1)
    expect(giSpy).toHaveBeenCalledTimes(1)
    expect(internals._indexEpochStale).toBe(true)
  })

  it('graph provider isMigrating(): its rebuild is skipped under epoch-drift; metadata + vector siblings still rebuild', async () => {
    const brain = await makeWarmBrain()
    const internals = internalsOf(brain)

    const miSpy = vi.spyOn(internals.metadataIndex, 'rebuild').mockResolvedValue(undefined)
    const idxSpy = vi.spyOn(internals.index, 'rebuild').mockResolvedValue(undefined)
    const giSpy = vi.spyOn(internals.graphIndex, 'rebuild').mockResolvedValue(undefined)

    setMigrating(internals.graphIndex, true)
    internals._indexEpochStale = true

    await internals.rebuildIndexesIfNeeded()

    expect(giSpy).toHaveBeenCalledTimes(0)
    expect(miSpy).toHaveBeenCalledTimes(1)
    expect(idxSpy).toHaveBeenCalledTimes(1)
    expect(internals._indexEpochStale).toBe(true)
  })

  it('a migrating provider is skipped even though its empty-signal would trigger a rebuild; clearing the flag lets the empty leg rebuild', async () => {
    const brain = await makeWarmBrain()
    const internals = internalsOf(brain)

    const miSpy = vi.spyOn(internals.metadataIndex, 'rebuild').mockResolvedValue(undefined)
    const idxSpy = vi.spyOn(internals.index, 'rebuild').mockResolvedValue(undefined)
    const giSpy = vi.spyOn(internals.graphIndex, 'rebuild').mockResolvedValue(undefined)

    // No epoch drift: the only rebuild trigger is a leg's own empty signal. The
    // JS vector's rebuild() IS its load path, so size()===0 is its trigger —
    // the leg where "empty → rebuild" is architecturally correct — so we drive
    // deference through it. (The JS graph cold-loads before this gate, so its
    // size()===0 is a valid empty state, not a rebuild trigger; graph deference
    // is covered by the epoch-drift case above.)
    internals._indexEpochStale = false
    vi.spyOn(internals.index, 'size').mockReturnValue(0)

    // Migrating: the provider's background swap owns the index, so the
    // size()===0 load trigger is suppressed.
    setMigrating(internals.index, true)
    await internals.rebuildIndexesIfNeeded()
    expect(idxSpy).toHaveBeenCalledTimes(0)
    // Metadata has entries; the graph has no edges; neither drifted → no rebuild.
    expect(miSpy).toHaveBeenCalledTimes(0)
    expect(giSpy).toHaveBeenCalledTimes(0)

    // Clearing the flag: the same empty, non-migrating vector now rebuilds — and
    // this second call re-evaluating at all confirms the gate is not latched.
    setMigrating(internals.index, false)
    await internals.rebuildIndexesIfNeeded()
    expect(idxSpy).toHaveBeenCalledTimes(1)
  })

  // --- Hook 1: large-path first-query lazy force-rebuild deference ----------

  it('lazy first-query force-rebuild is SKIPPED when the vector provider isMigrating()', async () => {
    // disableAutoRebuild routes first queries through ensureIndexesLoaded() (the
    // large-brain lazy path that would otherwise force a blocking rebuild).
    const brain = await makeWarmBrain(2, { disableAutoRebuild: true })
    const internals = internalsOf(brain)

    const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined)
    // Simulate a cold/empty live vector index (cor is mid-swap, serving canonical).
    vi.spyOn(internals.index, 'size').mockReturnValue(0)
    internals.lazyRebuildCompleted = false
    setMigrating(internals.index, true)

    await internals.ensureIndexesLoaded()

    // A query during cor's background swap must not trigger brainy's blocking rebuild.
    expect(rebuildSpy).toHaveBeenCalledTimes(0)
  })

  it('lazy first-query force-rebuild STILL fires when the vector provider is not migrating (control)', async () => {
    const brain = await makeWarmBrain(2, { disableAutoRebuild: true })
    const internals = internalsOf(brain)

    const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined)
    vi.spyOn(internals.index, 'size').mockReturnValue(0)
    internals.lazyRebuildCompleted = false
    // No isMigrating → not deferring.

    await internals.ensureIndexesLoaded()

    // Without deference, the cold empty index drives the lazy force-rebuild.
    expect(rebuildSpy).toHaveBeenCalledTimes(1)
    expect(rebuildSpy).toHaveBeenCalledWith(true)
  })

  // --- Hook 2: public stampBrainFormat() -----------------------------------

  it('brain.stampBrainFormat() writes _system/brain-format.json with the current {dataFormat, indexEpoch}', async () => {
    const brain = new Brainy(createTestConfig())
    await brain.init()
    brains.push(brain)
    const internals = internalsOf(brain)

    // Spy AFTER init so only the stamp call's write is observed (init already
    // stamped the fresh brain).
    const writeSpy = vi.spyOn(BaseStorage.prototype, 'writeRawObject')

    await brain.stampBrainFormat()

    const stampWrites = writeSpy.mock.calls.filter(([p]) => p === BRAIN_FORMAT_PATH)
    expect(stampWrites.length).toBe(1)
    expect(stampWrites[0][1]).toEqual(CURRENT_MARKER)

    // And the marker is durable on disk at the current epoch.
    const onDisk = await internals.storage.readRawObject(BRAIN_FORMAT_PATH)
    expect(onDisk).toEqual(CURRENT_MARKER)
  })

  // --- Hook 3: marker module export ----------------------------------------

  it('the brain-format marker module exports the compiled epoch + data-format constants', () => {
    // cor imports these from '@soulcraft/brainy/brain-format' (Hook 3) so both
    // sides share ONE source of truth — no duplicated constant to drift.
    expect(EXPECTED_INDEX_EPOCH).toBe(1)
    expect(CURRENT_DATA_FORMAT).toBe('8.0')
  })
})
