/**
 * @module tests/unit/transaction/timeout-rollback
 * @description Regression for a consumer-reported P0 data-integrity bug:
 * `Transaction.execute()` checked its time budget at the TOP of the operation
 * loop and threw `TransactionTimeoutError` from OUTSIDE the per-operation
 * try/catch — so the timeout bypassed rollback entirely (only per-operation
 * failures rolled back). On a bulk transact that tripped the 30s budget
 * mid-flight, the operations already applied to canonical storage were left in
 * place while the generation was never stamped: torn, generation-less state.
 * The generation-store commit path's abort cleanup explicitly ASSUMES a throw
 * from `execute()` already restored the applied operations byte-identically
 * (it only discards the uncommitted staging directory), so the missing
 * rollback broke that invariant.
 *
 * Fix: `execute()` now has a SINGLE rollback point — any error escaping the
 * operation loop (operation failure OR mid-flight timeout) rolls back every
 * applied operation in reverse order, then surfaces the original error.
 *
 * These tests model canonical storage as an in-memory map and give each
 * operation a real rollback action that restores the map byte-identically —
 * exercising the exact fixed code path and asserting the reported requirement:
 * a mid-flight timeout leaves storage byte-identical to its pre-transaction
 * state.
 */
import { describe, it, expect } from 'vitest'
import { Transaction } from '../../../src/transaction/Transaction.js'
import type { Operation, RollbackAction } from '../../../src/transaction/types.js'
import {
  TransactionTimeoutError,
  TransactionExecutionError,
  TransactionRollbackError
} from '../../../src/transaction/errors.js'

/** A tiny stand-in for canonical storage. */
type Store = Map<string, string>

const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))

/**
 * An operation that writes `value` at `key` in `store` and returns a rollback
 * action restoring the key's PRIOR state byte-identically (delete if it was
 * absent, else restore the previous value) — exactly how the real graph/metadata
 * operations undo themselves. `delayMs` lets an op consume wall-clock so a small
 * transaction budget trips on the NEXT loop iteration; `fail` makes it throw
 * after doing its write-and-rollback capture (to exercise the shared path via an
 * operation failure rather than a timeout).
 */
function writeOp(
  store: Store,
  key: string,
  value: string,
  opts: { delayMs?: number; fail?: boolean; name?: string } = {}
): Operation & { executed: boolean } {
  const op = {
    name: opts.name ?? `write:${key}`,
    executed: false,
    async execute(): Promise<RollbackAction | undefined> {
      op.executed = true
      if (opts.delayMs) await sleep(opts.delayMs)
      // Operations are individually atomic: a failing op throws having left NO
      // net change (it never gets far enough to register an undo), so only the
      // PRIOR operations need rolling back.
      if (opts.fail) throw new Error(`operation ${key} failed`)
      const had = store.has(key)
      const prev = store.get(key)
      store.set(key, value)
      return async () => {
        if (had) store.set(key, prev as string)
        else store.delete(key)
      }
    }
  }
  return op
}

describe('Transaction — mid-flight timeout rolls back (P0 data integrity)', () => {
  it('a mid-flight timeout leaves storage BYTE-IDENTICAL to its pre-transaction state', async () => {
    const store: Store = new Map([['seed', 'unchanged']])
    const snapshot = new Map(store)

    // Budget 1ms; op0 sleeps 60ms so the budget is blown when the loop checks
    // it before op1 — a deterministic mid-flight timeout at operation index 1.
    const op0 = writeOp(store, 'A', 'applied-then-rolled-back', { delayMs: 60 })
    const op1 = writeOp(store, 'B', 'never-applied')

    const tx = new Transaction({ timeout: 1 })
    tx.addOperation(op0)
    tx.addOperation(op1)

    await expect(tx.execute()).rejects.toBeInstanceOf(TransactionTimeoutError)

    // op0 ran (and was rolled back); op1 never started.
    expect(op0.executed).toBe(true)
    expect(op1.executed).toBe(false)
    // The whole point: canonical storage is byte-identical — no torn state.
    expect(store).toEqual(snapshot)
    expect(store.has('A')).toBe(false)
    expect(store.has('B')).toBe(false)
    // And the transaction ended in the rolled-back terminal state (so the
    // manager counts it correctly), not stranded in 'executing'.
    expect(tx.getState()).toBe('rolled_back')
  })

  it('an operation failure mid-flight rolls back byte-identically (the shared single-rollback path)', async () => {
    const store: Store = new Map([['seed', 'unchanged']])
    const snapshot = new Map(store)

    const op0 = writeOp(store, 'A', 'applied-then-rolled-back')
    const op1 = writeOp(store, 'B', 'partially-applied', { fail: true })
    const op2 = writeOp(store, 'C', 'never-applied')

    const tx = new Transaction()
    tx.addOperation(op0)
    tx.addOperation(op1)
    tx.addOperation(op2)

    await expect(tx.execute()).rejects.toBeInstanceOf(TransactionExecutionError)

    expect(op0.executed).toBe(true)
    expect(op1.executed).toBe(true)
    expect(op2.executed).toBe(false)
    // op1's partial write is undone by its own rollback; op0's write is undone;
    // op2 never wrote. Byte-identical.
    expect(store).toEqual(snapshot)
    expect(tx.getState()).toBe('rolled_back')
  })

  it('a rollback failure during a timeout surfaces TransactionRollbackError wrapping the timeout (loud, not silent)', async () => {
    const store: Store = new Map()

    // op0 applies, but its rollback throws every time (maxRollbackRetries
    // exhausted) — the manager must surface a TransactionRollbackError whose
    // originalError is the timeout, never swallow it.
    const op0: Operation & { executed: boolean } = {
      name: 'unrollbackable',
      executed: false,
      async execute() {
        op0.executed = true
        await sleep(60)
        store.set('A', 'applied')
        return async () => {
          throw new Error('rollback is impossible for this op')
        }
      }
    }
    const op1 = writeOp(store, 'B', 'never-applied')

    const tx = new Transaction({ timeout: 1, maxRollbackRetries: 1 })
    tx.addOperation(op0)
    tx.addOperation(op1)

    let caught: unknown
    await tx.execute().catch((e) => (caught = e))

    expect(caught).toBeInstanceOf(TransactionRollbackError)
    expect((caught as TransactionRollbackError).originalError).toBeInstanceOf(
      TransactionTimeoutError
    )
    expect(op1.executed).toBe(false)
  })

  it('a clean (non-timeout) transaction still commits and applies all writes', async () => {
    const store: Store = new Map()
    const op0 = writeOp(store, 'A', 'a')
    const op1 = writeOp(store, 'B', 'b')

    const tx = new Transaction()
    tx.addOperation(op0)
    tx.addOperation(op1)

    await tx.execute()

    expect(tx.getState()).toBe('committed')
    expect(store.get('A')).toBe('a')
    expect(store.get('B')).toBe('b')
  })
})
