/**
 * @module brain-format-handshake.test
 * @description Tests for the 7.x → 8.0 version-handshake marker (GA #30): the
 * `_system/brain-format.json` surface cor reads to drive whole-brain
 * auto-upgrade, and Brainy's own JS-index rebuild-on-format-drift trigger.
 *
 * The contract (converged with the cor team — see `src/storage/brainFormat.ts`):
 *
 * - `formatInfo()` is a SYNC accessor returning the running build's
 *   `{ dataFormat, indexEpoch }` (the compiled constants).
 * - On open, an on-disk `indexEpoch` that differs from `EXPECTED_INDEX_EPOCH`,
 *   or an absent marker, means the derived JS indexes are stale and must be
 *   rebuilt from the canonical records, then the marker is re-stamped — but
 *   only AFTER the rebuild verifies (non-destructive: a crash mid-rebuild
 *   leaves the old / absent marker, so the next open idempotently re-rebuilds).
 *
 * Persistence note: the derived JS indexes are in-memory and cold-load via
 * `rebuild()` on every reopen, so "did a rebuild happen" is not, on its own, a
 * signal that the EPOCH triggered it. The epoch-force (rebuild past the warm
 * `size()>0` fast path) is therefore proven by a focused white-box test; the
 * reopen tests assert the observable handshake effects — the marker content,
 * whether it was re-stamped, and the rebuild-before-stamp ordering.
 */

import { describe, it, expect, afterEach, vi } from 'vitest'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { Brainy } from '../../../src/index.js'
import { NounType } from '../../../src/types/graphTypes.js'
import { BaseStorage } from '../../../src/storage/baseStorage.js'
import { FileSystemStorage } from '../../../src/storage/adapters/fileSystemStorage.js'
import { MetadataIndexManager } from '../../../src/utils/metadataIndex.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 }

const tempDirs: string[] = []
const brains: Brainy[] = []

function makeTempDir(): string {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-format-handshake-'))
  tempDirs.push(dir)
  return dir
}

/** Open (and track) a filesystem brain rooted at `dir`. */
async function openFsBrain(dir: string): Promise<Brainy> {
  const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
  await brain.init()
  brains.push(brain)
  return brain
}

/** Read the on-disk marker through a fresh raw adapter (after the brain closed). */
async function readMarkerFromDisk(dir: string): Promise<unknown> {
  const adapter = new FileSystemStorage(dir)
  await adapter.init()
  return adapter.readRawObject(BRAIN_FORMAT_PATH)
}

/** Overwrite (or delete, when `marker` is null) the on-disk marker between reopens. */
async function seedMarkerOnDisk(dir: string, marker: unknown | null): Promise<void> {
  const adapter = new FileSystemStorage(dir)
  await adapter.init()
  if (marker === null) {
    await adapter.deleteRawObject(BRAIN_FORMAT_PATH)
  } else {
    await adapter.writeRawObject(BRAIN_FORMAT_PATH, marker)
  }
}

afterEach(async () => {
  for (const brain of brains.splice(0)) {
    try {
      await brain.close()
    } catch {
      // already closed by the test
    }
  }
  vi.restoreAllMocks()
  for (const dir of tempDirs.splice(0)) {
    await fs.promises.rm(dir, { recursive: true, force: true })
  }
})

describe('8.0 ⇄ cor version-handshake marker (_system/brain-format.json)', () => {
  // (a) Fresh brain → formatInfo() is the current format; the marker is written.
  it('(a) fresh brain: formatInfo() reports the current format and writes the marker', async () => {
    const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
    await brain.init()
    brains.push(brain)

    expect(brain.formatInfo()).toEqual(CURRENT_MARKER)

    const onDisk = await (brain as unknown as {
      storage: { readRawObject(p: string): Promise<unknown> }
    }).storage.readRawObject(BRAIN_FORMAT_PATH)
    expect(onDisk).toEqual(CURRENT_MARKER)
  })

  // (b) Reopen a current-epoch brain → no re-stamp; formatInfo() consistent.
  it('(b) reopen at the current epoch: no re-stamp, formatInfo() stays consistent', async () => {
    const dir = makeTempDir()
    const first = await openFsBrain(dir)
    await first.add({ data: 'x', type: NounType.Document, metadata: { k: 1 } })
    await first.close()
    brains.splice(brains.indexOf(first), 1)

    // The on-disk marker is present and current; reopening must NOT rewrite it.
    const writeSpy = vi.spyOn(BaseStorage.prototype, 'writeRawObject')
    const second = await openFsBrain(dir)

    const stampWrites = writeSpy.mock.calls.filter(([p]) => p === BRAIN_FORMAT_PATH)
    expect(stampWrites.length).toBe(0)
    expect((second as unknown as { _indexEpochStale: boolean })._indexEpochStale).toBe(false)
    expect(second.formatInfo()).toEqual(CURRENT_MARKER)
  })

  // (c) Stale on-disk epoch → derived-index rebuild, THEN re-stamp (rebuild before stamp).
  it('(c) stale on-disk epoch: rebuilds the derived indexes, then re-stamps (rebuild BEFORE stamp)', async () => {
    const dir = makeTempDir()
    const first = await openFsBrain(dir)
    await first.add({ data: 'x', type: NounType.Document, metadata: { k: 1 } })
    await first.close()
    brains.splice(brains.indexOf(first), 1)

    // Simulate a brain written by an OLDER build: drift the epoch back to 0.
    await seedMarkerOnDisk(dir, { dataFormat: CURRENT_DATA_FORMAT, indexEpoch: EXPECTED_INDEX_EPOCH - 1 })

    // Spy AFTER the seed write so only the reopen's calls are observed. Both
    // spies call through; vitest's `invocationCallOrder` is a single global
    // counter, so cross-spy ordering is comparable.
    const rebuildSpy = vi.spyOn(MetadataIndexManager.prototype, 'rebuild')
    const writeSpy = vi.spyOn(BaseStorage.prototype, 'writeRawObject')

    const second = await openFsBrain(dir)

    // The marker was re-stamped exactly once, to the EXPECTED epoch.
    const stampOrders = writeSpy.mock.calls
      .map((call, i) => ({ path: call[0] as string, order: writeSpy.mock.invocationCallOrder[i] }))
      .filter((c) => c.path === BRAIN_FORMAT_PATH)
    expect(stampOrders.length).toBe(1)
    expect((second as unknown as { _indexEpochStale: boolean })._indexEpochStale).toBe(false)

    // The derived index rebuilt, and the rebuild happened BEFORE the stamp
    // (non-destructive ordering: the marker only advances once the rebuild that
    // it certifies has run).
    expect(rebuildSpy).toHaveBeenCalled()
    const firstRebuildOrder = rebuildSpy.mock.invocationCallOrder[0]
    expect(firstRebuildOrder).toBeLessThan(stampOrders[0].order)

    await second.close()
    brains.splice(brains.indexOf(second), 1)

    // And the new marker is durable on disk at the EXPECTED epoch.
    expect(await readMarkerFromDisk(dir)).toEqual(CURRENT_MARKER)
  })

  // (d) No marker (pre-handshake brain) → treated as stale → rebuild + stamp.
  it('(d) no marker (pre-handshake brain): treated as stale → rebuilds and stamps the marker', async () => {
    const dir = makeTempDir()
    const first = await openFsBrain(dir)
    await first.add({ data: 'x', type: NounType.Document, metadata: { k: 1 } })
    await first.close()
    brains.splice(brains.indexOf(first), 1)

    // A brain written before the handshake existed has NO marker on disk.
    await seedMarkerOnDisk(dir, null)
    expect(await readMarkerFromDisk(dir)).toBeNull()

    const rebuildSpy = vi.spyOn(MetadataIndexManager.prototype, 'rebuild')
    const writeSpy = vi.spyOn(BaseStorage.prototype, 'writeRawObject')

    const second = await openFsBrain(dir)

    expect(rebuildSpy).toHaveBeenCalled()
    const stampWrites = writeSpy.mock.calls.filter(([p]) => p === BRAIN_FORMAT_PATH)
    expect(stampWrites.length).toBe(1)
    expect((second as unknown as { _indexEpochStale: boolean })._indexEpochStale).toBe(false)

    await second.close()
    brains.splice(brains.indexOf(second), 1)
    expect(await readMarkerFromDisk(dir)).toEqual(CURRENT_MARKER)
  })

  // The epoch-drift trigger itself: a format change forces a rebuild past the
  // warm-index fast path (the gap this feature closes); a current epoch does not.
  it('epoch drift forces a rebuild of all three indexes past the warm fast path; current epoch does not', async () => {
    const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
    await brain.init()
    brains.push(brain)

    // Warm the indexes (size() > 0) so the fast-path early return is live.
    await brain.add({ data: 'a', type: NounType.Document, metadata: { k: 1 } })
    await brain.add({ data: 'b', type: NounType.Document, metadata: { k: 2 } })
    const internals = brain as unknown as {
      index: { size(): number; rebuild(...a: unknown[]): Promise<unknown> }
      metadataIndex: { rebuild(...a: unknown[]): Promise<unknown> }
      graphIndex: { rebuild(...a: unknown[]): Promise<unknown> }
      _indexEpochStale: boolean
      rebuildIndexesIfNeeded(force?: boolean): Promise<void>
    }
    expect(internals.index.size()).toBeGreaterThan(0)

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

    // Current epoch: the warm fast path skips every rebuild.
    internals._indexEpochStale = false
    await internals.rebuildIndexesIfNeeded()
    expect(idxSpy).not.toHaveBeenCalled()
    expect(miSpy).not.toHaveBeenCalled()
    expect(giSpy).not.toHaveBeenCalled()

    // Drifted epoch: all three rebuild even though the indexes are non-empty.
    internals._indexEpochStale = true
    await internals.rebuildIndexesIfNeeded()
    expect(idxSpy).toHaveBeenCalledTimes(1)
    expect(miSpy).toHaveBeenCalledTimes(1)
    expect(giSpy).toHaveBeenCalledTimes(1)
    // The stamp cleared the stale flag after the verified rebuild.
    expect(internals._indexEpochStale).toBe(false)
  })

  // (e) formatInfo() is synchronously available immediately after init (no await).
  it('(e) formatInfo() is synchronously available immediately after init', async () => {
    const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
    await brain.init()
    brains.push(brain)

    // No await on the call itself — it is a pure in-memory constant read.
    const info = brain.formatInfo()
    expect(info).toEqual(CURRENT_MARKER)
    expect(typeof info.indexEpoch).toBe('number')
    expect(typeof info.dataFormat).toBe('string')
  })
})
