// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0

import type { TezosCoinConfig, TezosContext } from '../config'
import type {
  APIDelegationType,
  APIOriginationType,
  APIRevealType,
  APIStakingType,
  APITokenTransfer,
  APITransactionType,
} from '../network/types'
import type { OtherBlockOperation } from '@ledgerhq/coin-module-framework/api/types'
import { getBlock } from './getBlock'

const context: TezosContext = {
  config: async () => ({}) as unknown as TezosCoinConfig,
  logger: () => undefined,
}

// ---------------------------------------------------------------------------
// Network mock
// Using the same lazy-reference pattern as the XRP reference tests so that the
// `jest.mock` factory (which is hoisted) can reference variables declared below.
// ---------------------------------------------------------------------------

const mockGetBlockByLevel = jest.fn()
const mockGetOperationsOrigination = jest.fn()
const mockFetchBlockTransactions = jest.fn()
const mockFetchBlockTokenTransfers = jest.fn()
const mockFetchBlockDelegations = jest.fn()
const mockFetchBlockStaking = jest.fn()
const mockFetchBlockOriginations = jest.fn()
const mockFetchBlockReveals = jest.fn()

jest.mock('../network', () => ({
  createTzktApi: () => ({
    getBlockByLevel: (...args: unknown[]) => mockGetBlockByLevel(...args),
    getOperationsOrigination: (...args: unknown[]) => mockGetOperationsOrigination(...args),
  }),
  fetchBlockTransactions: (...args: unknown[]) => mockFetchBlockTransactions(...args),
  fetchBlockTokenTransfers: (...args: unknown[]) => mockFetchBlockTokenTransfers(...args),
  fetchBlockDelegations: (...args: unknown[]) => mockFetchBlockDelegations(...args),
  fetchBlockStaking: (...args: unknown[]) => mockFetchBlockStaking(...args),
  fetchBlockOriginations: (...args: unknown[]) => mockFetchBlockOriginations(...args),
  fetchBlockReveals: (...args: unknown[]) => mockFetchBlockReveals(...args),
}))

// ---------------------------------------------------------------------------
// Test-data factories
// ---------------------------------------------------------------------------

function makeBlock(level = 5_000_000, hash = 'BLockHash123', timestamp = '2024-01-01T00:00:00Z') {
  return { level, hash, timestamp } as any
}

function makeTx(overrides: Partial<APITransactionType> = {}): APITransactionType {
  return {
    id: 1,
    hash: 'opHash1',
    type: 'transaction',
    amount: 0,
    sender: { address: 'tz1Sender' },
    target: { address: 'tz1Target' },
    initiator: null,
    counter: 1,
    level: 5_000_000,
    block: 'BLockHash123',
    timestamp: '2024-01-01T00:00:00Z',
    bakerFee: 0,
    storageFee: 0,
    allocationFee: 0,
    status: 'applied',
    ...overrides,
  } as APITransactionType
}

function makeTokenTransfer(overrides: Partial<APITokenTransfer> = {}): APITokenTransfer {
  return {
    id: 100,
    level: 5_000_000,
    timestamp: '2024-01-01T00:00:00Z',
    token: {
      id: 1,
      contract: { address: 'KT1FATezosContract' },
      tokenId: '0',
      standard: 'fa2',
      metadata: { name: 'TezToken', symbol: 'TZT' },
    },
    from: { address: 'tz1Sender' },
    to: { address: 'tz1Target' },
    amount: '500',
    transactionId: 1,
    ...overrides,
  }
}

function makeStaking(overrides: Partial<APIStakingType> = {}): APIStakingType {
  return {
    id: 300,
    hash: 'opStaking1',
    type: 'staking',
    action: 'stake',
    amount: 500_000_000,
    requestedAmount: 500_000_000,
    sender: { address: 'tz1Staker' },
    staker: { address: 'tz1Staker' },
    baker: { address: 'tz1Baker', alias: 'TF Test Baker' },
    counter: 1,
    level: 5_000_000,
    block: 'BLockHash123',
    timestamp: '2024-01-01T00:00:00Z',
    bakerFee: 800,
    storageFee: 0,
    allocationFee: 0,
    gasLimit: 3630,
    storageLimit: 0,
    status: 'applied',
    ...overrides,
  } as APIStakingType
}

function makeDelegation(overrides: Partial<APIDelegationType> = {}): APIDelegationType {
  return {
    id: 200,
    hash: 'opDelegation1',
    type: 'delegation',
    amount: 0,
    sender: { address: 'tz1Delegator' },
    counter: 1,
    level: 5_000_000,
    block: 'BLockHash123',
    timestamp: '2024-01-01T00:00:00Z',
    bakerFee: 1000,
    storageFee: 0,
    allocationFee: 0,
    status: 'applied',
    prevDelegate: null,
    newDelegate: { address: 'tz1Baker' },
    ...overrides,
  } as APIDelegationType
}

function makeOrigination(overrides: Partial<APIOriginationType> = {}): APIOriginationType {
  return {
    id: 400,
    hash: 'opOrigination1',
    type: 'origination',
    sender: { address: 'tz1Deployer' },
    counter: 1,
    level: 5_000_000,
    block: 'BLockHash123',
    timestamp: '2024-01-01T00:00:00Z',
    bakerFee: 5000,
    storageFee: 1000000,
    allocationFee: 64250,
    status: 'applied',
    contractBalance: 0,
    originatedContract: { address: 'KT1NewContract' },
    ...overrides,
  } as APIOriginationType
}

function makeReveal(overrides: Partial<APIRevealType> = {}): APIRevealType {
  return {
    id: 500,
    hash: 'opReveal1',
    type: 'reveal',
    sender: { address: 'tz1Revealer' },
    counter: 1,
    level: 5_000_000,
    block: 'BLockHash123',
    timestamp: '2024-01-01T00:00:00Z',
    bakerFee: 1420,
    storageFee: 0,
    allocationFee: 0,
    status: 'applied',
    gasLimit: 10600,
    storageLimit: 0,
    ...overrides,
  } as APIRevealType
}

// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------

beforeEach(() => {
  jest.clearAllMocks()
  mockFetchBlockTransactions.mockResolvedValue([])
  mockFetchBlockTokenTransfers.mockResolvedValue([])
  mockFetchBlockDelegations.mockResolvedValue([])
  mockFetchBlockStaking.mockResolvedValue([])
  mockFetchBlockOriginations.mockResolvedValue([])
  mockFetchBlockReveals.mockResolvedValue([])
  mockGetOperationsOrigination.mockResolvedValue([])
})

// ---------------------------------------------------------------------------
// 1. Block info mapping and guard
// ---------------------------------------------------------------------------

describe('block info and guard', () => {
  it('maps level, hash, timestamp, and parent into Block.info', async () => {
    // Given
    const level = 7_000_000
    mockGetBlockByLevel.mockResolvedValueOnce(makeBlock(level, 'BLHash', '2025-06-15T12:00:00Z'))
    mockGetBlockByLevel.mockResolvedValueOnce(
      makeBlock(level - 1, 'BLParentHash', '2025-06-15T11:59:52Z')
    )

    // When
    const result = await getBlock(context, level)

    // Then
    expect(mockGetBlockByLevel).toHaveBeenCalledWith(level)
    expect(mockGetBlockByLevel).toHaveBeenCalledWith(level - 1)
    expect(result.info).toEqual({
      height: level,
      hash: 'BLHash',
      time: new Date('2025-06-15T12:00:00Z'),
      parent: { height: level - 1, hash: 'BLParentHash' },
    })
  })

  it('throws for height = 0 without any network calls', async () => {
    // When / Then
    await expect(getBlock(context, 0)).rejects.toThrow(
      'getBlock: height must be a positive integer, got 0'
    )
    expect(mockGetBlockByLevel).not.toHaveBeenCalled()
    expect(mockFetchBlockTransactions).not.toHaveBeenCalled()
    expect(mockFetchBlockTokenTransfers).not.toHaveBeenCalled()
  })

  it('throws for negative height without any network calls', async () => {
    // When / Then
    await expect(getBlock(context, -3)).rejects.toThrow(
      'getBlock: height must be a positive integer, got -3'
    )
    expect(mockGetBlockByLevel).not.toHaveBeenCalled()
  })

  it.each([NaN, 1.5, Infinity, -Infinity])(
    'throws for non-safe-integer height (%s) without any network calls',
    async (height) => {
      await expect(getBlock(context, height)).rejects.toThrow(
        'getBlock: height must be a positive integer'
      )
      expect(mockGetBlockByLevel).not.toHaveBeenCalled()
    }
  )

  it('returns an empty transactions array when the block has no operations', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions).toEqual([])
  })

  it('silently skips a transaction with no hash (covers !tx.hash branch and tx.id && tx.hash false branch)', async () => {
    // Given – one tx without a hash and one valid tx
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ hash: undefined as unknown as string, id: 5, amount: 500_000 }),
      makeTx({ id: 2, hash: 'validHash', amount: 1_000_000 }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then – only the tx with a valid hash is included
    expect(result.transactions).toHaveLength(1)
    expect(result.transactions[0].hash).toBe('validHash')
  })

  it('excludes a no-id transaction from the token-transfer lookup (covers tx.id falsy branch)', async () => {
    // Given – tx without id cannot be registered in txIdToHash
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ id: undefined as unknown as number, hash: 'noIdHash', amount: 2_000_000 }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then – the tx still appears (it has a hash), but no txIdToHash entry was created
    expect(result.transactions).toHaveLength(1)
    expect(result.transactions[0].hash).toBe('noIdHash')
  })
})

// ---------------------------------------------------------------------------
// 2. Native XTZ transfers
// ---------------------------------------------------------------------------

describe('native XTZ operations', () => {
  it('produces outgoing and incoming operations for a non-zero XTZ transfer', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ amount: 1_000_000, sender: { address: 'tz1Alice' }, target: { address: 'tz1Bob' } }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)
    const tx = result.transactions[0]

    // Then
    expect(tx.operations).toHaveLength(2)
    expect(tx.operations[0]).toEqual({
      type: 'transfer',
      address: 'tz1Alice',
      peer: 'tz1Bob',
      asset: { type: 'native', name: 'XTZ' },
      amount: -1_000_000n,
    })
    expect(tx.operations[1]).toEqual({
      type: 'transfer',
      address: 'tz1Bob',
      peer: 'tz1Alice',
      asset: { type: 'native', name: 'XTZ' },
      amount: 1_000_000n,
    })
  })

  it('produces no operations for a zero-amount, zero-fee transaction', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([makeTx({ amount: 0, bakerFee: 0 })])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then — no balance impact, no operations
    expect(result.transactions[0].operations).toEqual([])
  })

  it('produces operations for a zero-amount transaction that carries fees', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([makeTx({ amount: 0, bakerFee: 1500 })])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then — fees are a balance change, so operations must be emitted for fee attribution
    expect(result.transactions[0].fees).toBe(1500n)
    expect(result.transactions[0].operations).toEqual([
      {
        type: 'transfer',
        address: 'tz1Sender',
        peer: 'tz1Target',
        asset: { type: 'native', name: 'XTZ' },
        amount: 0n,
      },
      {
        type: 'transfer',
        address: 'tz1Target',
        peer: 'tz1Sender',
        asset: { type: 'native', name: 'XTZ' },
        amount: 0n,
      },
    ])
  })

  it('produces only an incoming operation when sender is null, with no peer field', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ amount: 500_000, sender: null, target: { address: 'tz1Receiver' } }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then — peer must be absent (not set to undefined) when the counterparty is unknown
    expect(result.transactions[0].operations).toHaveLength(1)
    expect(result.transactions[0].operations[0]).toEqual({
      type: 'transfer',
      address: 'tz1Receiver',
      asset: { type: 'native', name: 'XTZ' },
      amount: 500_000n,
    })
    expect(result.transactions[0].operations[0]).not.toHaveProperty('peer')
  })

  it('produces only an outgoing operation when target is null, with no peer field', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ amount: 300_000, sender: { address: 'tz1Sender' }, target: null }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then — peer must be absent (not set to undefined) when the counterparty is unknown
    expect(result.transactions[0].operations).toHaveLength(1)
    expect(result.transactions[0].operations[0]).toEqual({
      type: 'transfer',
      address: 'tz1Sender',
      asset: { type: 'native', name: 'XTZ' },
      amount: -300_000n,
    })
    expect(result.transactions[0].operations[0]).not.toHaveProperty('peer')
  })

  it('skips a transaction that has no hash', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ hash: undefined, amount: 1_000 }),
      makeTx({ id: 2, hash: 'opHash2', amount: 2_000 }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then — only the tx with a hash produces a BlockTransaction
    expect(result.transactions).toHaveLength(1)
    expect(result.transactions[0].hash).toBe('opHash2')
  })
})

// ---------------------------------------------------------------------------
// 3. Fees and feesPayer
// ---------------------------------------------------------------------------

describe('fee computation and fee payer', () => {
  it('sums bakerFee, storageFee, and allocationFee as the total fees', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ bakerFee: 400, storageFee: 100, allocationFee: 50 }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions[0].fees).toBe(550n)
  })

  it('uses sender as feesPayer for a top-level (non-contract) operation', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ bakerFee: 400, initiator: null, sender: { address: 'tz1FeePayer' } }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions[0].feesPayer).toBe('tz1FeePayer')
  })

  it('uses initiator as feesPayer when the operation is contract-initiated', async () => {
    // Given – top-level op has bakerFee > 0 and an initiator (the real fee payer)
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({
        bakerFee: 600,
        initiator: { address: 'tz1RealUser' },
        sender: { address: 'KT1Contract' },
      }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions[0].feesPayer).toBe('tz1RealUser')
  })

  it('omits feesPayer when both sender and initiator are null', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([makeTx({ sender: null, initiator: null })])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions[0].feesPayer).toBeUndefined()
  })

  it('treats null/undefined fee fields as zero (covers ?? 0 fallback branches)', async () => {
    // Given – a transaction where none of the fee fields are populated (possible for
    // implicit/internal ops that the protocol injects without explicit fees).
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({
        bakerFee: undefined as unknown as number,
        storageFee: undefined as unknown as number,
        allocationFee: undefined as unknown as number,
        amount: 1_000_000,
      }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then – all three ?? 0 branches are exercised; total fees must be 0n
    expect(result.transactions[0].fees).toBe(0n)
  })
})

// ---------------------------------------------------------------------------
// 4. Failed transactions
// ---------------------------------------------------------------------------

describe('failed transactions', () => {
  it("marks a transaction as failed when status is 'failed'", async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([makeTx({ status: 'failed', amount: 1_000_000 })])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions[0].failed).toBe(true)
    expect(result.transactions[0].operations).toEqual([])
  })

  it("marks a transaction as failed when status is 'backtracked'", async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ status: 'backtracked', amount: 1_000_000 }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions[0].failed).toBe(true)
    expect(result.transactions[0].operations).toEqual([])
  })

  it('marks the whole batch as failed when any op in the group is not applied', async () => {
    // Given – two ops share the same hash; the second one backtracked
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ id: 1, hash: 'opBatch', amount: 1_000, status: 'applied' }),
      makeTx({ id: 2, hash: 'opBatch', amount: 2_000, status: 'backtracked' }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions).toHaveLength(1)
    expect(result.transactions[0].failed).toBe(true)
    expect(result.transactions[0].operations).toEqual([])
  })

  it('treats operations with no status field as succeeded', async () => {
    // Given – status is undefined (some TzKT responses omit it)
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([makeTx({ status: undefined, amount: 100_000 })])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions[0].failed).toBe(false)
    expect(result.transactions[0].operations).toHaveLength(2)
  })
})

// ---------------------------------------------------------------------------
// 5. Batch and internal operations
// ---------------------------------------------------------------------------

describe('batch and internal operations', () => {
  it('merges multiple ops sharing the same hash into a single BlockTransaction', async () => {
    // Given – two operations in a batch
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ id: 1, hash: 'opBatch', amount: 1_000_000, bakerFee: 400, storageFee: 0 }),
      makeTx({
        id: 2,
        hash: 'opBatch',
        amount: 2_000_000,
        bakerFee: 0,
        storageFee: 100,
        sender: { address: 'tz1Second' },
        target: { address: 'tz1ThirdParty' },
      }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then – one BlockTransaction with both ops' ops and combined fees
    expect(result.transactions).toHaveLength(1)
    const tx = result.transactions[0]
    expect(tx.hash).toBe('opBatch')
    expect(tx.fees).toBe(500n) // 400 + 0 + 0 + 0 + 100 + 0
    expect(tx.operations).toHaveLength(4) // sender+target for each of the 2 ops
  })

  it('uses the bakerFee-bearing op to determine feesPayer in a batch', async () => {
    // Given – first op has no bakerFee; second op has bakerFee > 0
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ id: 1, hash: 'opBatch', bakerFee: 0, sender: { address: 'KT1Contract' } }),
      makeTx({ id: 2, hash: 'opBatch', bakerFee: 500, sender: { address: 'tz1ActualPayer' } }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions[0].feesPayer).toBe('tz1ActualPayer')
  })

  it('two distinct hashes produce two BlockTransactions', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ id: 1, hash: 'opHash1' }),
      makeTx({ id: 2, hash: 'opHash2' }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions).toHaveLength(2)
    expect(result.transactions.map((t) => t.hash)).toEqual(
      expect.arrayContaining(['opHash1', 'opHash2'])
    )
  })
})

// ---------------------------------------------------------------------------
// 6. FA token transfers
// ---------------------------------------------------------------------------

describe('FA token transfers', () => {
  it('appends token ops to the matching native BlockTransaction', async () => {
    // Given – native tx id=1, token transfer transactionId=1
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ id: 1, hash: 'opHash1', amount: 100_000, bakerFee: 400 }),
    ])
    mockFetchBlockTokenTransfers.mockResolvedValue([
      makeTokenTransfer({ transactionId: 1, amount: '500' }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then – one BlockTransaction with 2 native ops + 2 token ops
    expect(result.transactions).toHaveLength(1)
    const tx = result.transactions[0]
    expect(tx.operations).toHaveLength(4)
    const tokenOps = tx.operations.filter(
      (op) => op.type === 'transfer' && 'asset' in op && (op as any).asset.type === 'fa2'
    )
    expect(tokenOps).toHaveLength(2)
  })

  it('does NOT append token ops to a failed BlockTransaction', async () => {
    // Given – parent tx failed; token transfer would be meaningless
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ id: 1, hash: 'opFailed', amount: 100_000, status: 'failed' }),
    ])
    mockFetchBlockTokenTransfers.mockResolvedValue([
      makeTokenTransfer({ transactionId: 1, amount: '500' }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then – failed tx has no operations at all
    expect(result.transactions).toHaveLength(1)
    expect(result.transactions[0].failed).toBe(true)
    expect(result.transactions[0].operations).toEqual([])
  })

  it('creates a standalone BlockTransaction for a token transfer with no parent tx', async () => {
    // Given – token transfer with transactionId=99 but no matching native tx
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTokenTransfers.mockResolvedValue([
      makeTokenTransfer({ id: 200, transactionId: 99, amount: '1000' }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then – one standalone BlockTransaction with 2 token ops
    expect(result.transactions).toHaveLength(1)
    const tx = result.transactions[0]
    expect(tx.hash).toBe('txid-99') // synthetic but unique, derived from transactionId
    expect(tx.failed).toBe(false)
    expect(tx.fees).toBe(0n)
    expect(tx.operations).toHaveLength(2)
  })

  it('groups multiple token transfers sharing the same transactionId into one standalone entry', async () => {
    // Given – two token transfers both orphaned but from the same parent id=42
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTokenTransfers.mockResolvedValue([
      makeTokenTransfer({ id: 1, transactionId: 42, amount: '100' }),
      makeTokenTransfer({
        id: 2,
        transactionId: 42,
        amount: '200',
        from: { address: 'tz1AnotherSender' },
        to: { address: 'tz1AnotherReceiver' },
      }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then – merged into one standalone BlockTransaction with 4 ops (2 per transfer)
    expect(result.transactions).toHaveLength(1)
    expect(result.transactions[0].operations).toHaveLength(4)
  })

  it('creates a separate standalone entry for each token transfer without a transactionId', async () => {
    // Given – two protocol-level transfers with no transactionId
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTokenTransfers.mockResolvedValue([
      makeTokenTransfer({ id: 1, transactionId: undefined, amount: '100' }),
      makeTokenTransfer({ id: 2, transactionId: undefined, amount: '200' }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then – each gets its own BlockTransaction
    expect(result.transactions).toHaveLength(2)
  })

  it('emits only an incoming operation for a minting event (no `from`), with no peer field', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTokenTransfers.mockResolvedValue([
      makeTokenTransfer({
        transactionId: undefined,
        from: null,
        to: { address: 'tz1Receiver' },
        amount: '750',
      }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then — peer must be absent when mint has no sender
    expect(result.transactions[0].operations).toHaveLength(1)
    expect(result.transactions[0].operations[0]).not.toHaveProperty('peer')
    expect(result.transactions[0].operations[0]).toMatchObject({
      type: 'transfer',
      address: 'tz1Receiver',
      amount: 750n,
    })
  })

  it('emits only an outgoing operation for a burning event (no `to`), with no peer field', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTokenTransfers.mockResolvedValue([
      makeTokenTransfer({
        transactionId: undefined,
        from: { address: 'tz1Burner' },
        to: null,
        amount: '300',
      }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then — peer must be absent when burn has no receiver
    expect(result.transactions[0].operations).toHaveLength(1)
    expect(result.transactions[0].operations[0]).not.toHaveProperty('peer')
    expect(result.transactions[0].operations[0]).toMatchObject({
      type: 'transfer',
      address: 'tz1Burner',
      amount: -300n,
    })
  })

  it('produces no operations for a zero-amount FA token transfer', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTokenTransfers.mockResolvedValue([
      makeTokenTransfer({ transactionId: undefined, amount: '0' }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then – zero-amount transfer is silently discarded
    expect(result.transactions).toHaveLength(0)
  })

  it("encodes assetReference as 'contract:tokenId' to uniquely identify FA2 tokens", async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTokenTransfers.mockResolvedValue([
      makeTokenTransfer({
        transactionId: undefined,
        token: {
          id: 5,
          contract: { address: 'KT1SpecificContract' },
          tokenId: '7',
          standard: 'fa2',
          metadata: { name: 'MyFA2Token', symbol: 'MFT' },
        },
        amount: '100',
      }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then — tokenId "7" must be part of the reference so different FA2 tokens under
    // the same contract are not conflated.
    const op = result.transactions[0].operations[0] as any
    expect(op.asset).toMatchObject({
      type: 'fa2',
      assetReference: 'KT1SpecificContract:7',
      name: 'MyFA2Token',
    })
  })

  it('falls back to symbol as token name when metadata name is absent', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTokenTransfers.mockResolvedValue([
      makeTokenTransfer({
        transactionId: undefined,
        token: {
          id: 6,
          contract: { address: 'KT1NoName' },
          tokenId: '0',
          standard: 'fa1.2',
          metadata: { symbol: 'NNT' }, // no name field
        },
        amount: '50',
      }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    const op = result.transactions[0].operations[0] as any
    expect(op.asset.name).toBe('NNT')
  })
})

// ---------------------------------------------------------------------------
// 7. Delegation operations
// ---------------------------------------------------------------------------

describe('delegation operations', () => {
  it('creates a BlockTransaction for a DELEGATE operation with correct details', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockDelegations.mockResolvedValue([
      makeDelegation({
        sender: { address: 'tz1Delegator' },
        newDelegate: { address: 'tz1Baker' },
        prevDelegate: null,
        counter: 42,
        gasLimit: 1000,
        storageLimit: 257,
      }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions).toHaveLength(1)
    const tx = result.transactions[0]
    expect(tx.hash).toBe('opDelegation1')
    expect(tx.failed).toBe(false)
    expect(tx.feesPayer).toBe('tz1Delegator')
    expect(tx.operations).toHaveLength(1)
    expect(tx.operations[0]).toEqual({
      type: 'other',
      ledgerOpType: 'DELEGATE',
      operationType: 'DELEGATE',
      stakedAmount: 0,
      counter: 42,
      gasLimit: 1000,
      storageLimit: 257,
    })
  })

  it('creates a BlockTransaction for an UNDELEGATE operation with correct details', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockDelegations.mockResolvedValue([
      makeDelegation({
        sender: { address: 'tz1Delegator' },
        newDelegate: null,
        prevDelegate: { address: 'tz1PrevBaker' },
        counter: 7,
        gasLimit: 500,
        storageLimit: 100,
      }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions).toHaveLength(1)
    const tx = result.transactions[0]
    expect(tx.operations).toHaveLength(1)
    expect(tx.operations[0]).toEqual({
      type: 'other',
      ledgerOpType: 'UNDELEGATE',
      operationType: 'UNDELEGATE',
      stakedAmount: 0,
      counter: 7,
      gasLimit: 500,
      storageLimit: 100,
    })
  })

  it('computes delegation fees from bakerFee, storageFee, and allocationFee', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockDelegations.mockResolvedValue([
      makeDelegation({ bakerFee: 500, storageFee: 100, allocationFee: 50 }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions[0].fees).toBe(650n)
  })

  it("marks a delegation as failed when status is not 'applied'", async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockDelegations.mockResolvedValue([makeDelegation({ status: 'failed' })])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then — failed but operations still present (listOperations path always includes them)
    expect(result.transactions[0].failed).toBe(true)
    expect(result.transactions[0].operations).toHaveLength(1)
  })

  it('skips delegations without a hash', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockDelegations.mockResolvedValue([
      makeDelegation({ hash: undefined as unknown as string }),
      makeDelegation({ id: 201, hash: 'opDelegation2' }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions).toHaveLength(1)
    expect(result.transactions[0].hash).toBe('opDelegation2')
  })

  it('creates delegation with empty operations when sender is missing', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockDelegations.mockResolvedValue([makeDelegation({ sender: null })])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions).toHaveLength(1)
    expect(result.transactions[0].operations).toEqual([])
  })

  it('merges delegation into existing transaction when they share the same hash', async () => {
    // Given - transaction and delegation batched together (same hash)
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ hash: 'opSharedHash', amount: 1_000_000, bakerFee: 500 }),
    ])
    mockFetchBlockDelegations.mockResolvedValue([
      makeDelegation({ hash: 'opSharedHash', bakerFee: 300 }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then - single BlockTransaction with both transfer and delegation operations, combined fees
    expect(result.transactions).toHaveLength(1)
    const tx = result.transactions[0]
    expect(tx.operations.some((op) => op.type === 'transfer')).toBe(true)
    expect(tx.operations.some((op) => op.type === 'other')).toBe(true)
    expect(tx.fees).toBe(800n) // 500 + 300
  })

  it('marks merged transaction as failed and clears operations if delegation failed', async () => {
    // Given - transaction succeeded but delegation in same batch failed
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ hash: 'opSharedHash', amount: 1_000_000, status: 'applied', bakerFee: 500 }),
    ])
    mockFetchBlockDelegations.mockResolvedValue([
      makeDelegation({ hash: 'opSharedHash', status: 'failed', bakerFee: 300 }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then - the whole batch is marked as failed, operations cleared, fees accumulated
    expect(result.transactions).toMatchObject([{ failed: true, operations: [], fees: 800n }])
  })

  it('does not add delegation operations to failed transaction', async () => {
    // Given - transaction failed, delegation would be backtracked anyway
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ hash: 'opSharedHash', amount: 1_000_000, status: 'failed' }),
    ])
    mockFetchBlockDelegations.mockResolvedValue([
      makeDelegation({ hash: 'opSharedHash', status: 'applied' }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then - no operations (tx was failed), but fees still accumulated
    expect(result.transactions).toMatchObject([{ failed: true, operations: [], fees: 1000n }])
  })

  it('includes both transactions and delegations in the same block', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([makeTx({ hash: 'opTx1', amount: 1_000_000 })])
    mockFetchBlockDelegations.mockResolvedValue([makeDelegation({ hash: 'opDelegation1' })])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions).toHaveLength(2)
    const hashes = result.transactions.map((t) => t.hash)
    expect(hashes).toContain('opTx1')
    expect(hashes).toContain('opDelegation1')
  })

  it('treats delegations with no status field as succeeded', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockDelegations.mockResolvedValue([makeDelegation({ status: undefined })])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions[0].failed).toBe(false)
    expect(result.transactions[0].operations).toHaveLength(1)
  })

  it('omits feesPayer when sender is missing', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockDelegations.mockResolvedValue([makeDelegation({ sender: null })])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    expect(result.transactions[0].feesPayer).toBeUndefined()
  })

  it('omits delegate field when neither newDelegate nor prevDelegate is present', async () => {
    // Given - edge case: both delegates are null
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockDelegations.mockResolvedValue([
      makeDelegation({ newDelegate: null, prevDelegate: null }),
    ])

    // When
    const result = await getBlock(context, 5_000_000)

    // Then
    const op = result.transactions[0].operations[0] as Record<string, unknown>
    expect(op).not.toHaveProperty('delegate')
    expect(op.ledgerOpType).toBe('UNDELEGATE')
  })
})

// ---------------------------------------------------------------------------
// 7b. Staking operations (Paris adaptive issuance)
// ---------------------------------------------------------------------------

describe('staking operations', () => {
  it.each([
    ['stake', 'STAKE', 500_000_000],
    ['unstake', 'UNSTAKE', 250_000_000],
    ['finalize', 'FINALIZE_UNSTAKE', 0],
  ] as const)(
    'creates a BlockTransaction for action=%s with ledgerOpType=%s',
    async (action, expectedOpType, expectedStakedAmount) => {
      mockGetBlockByLevel.mockResolvedValue(makeBlock())
      mockFetchBlockStaking.mockResolvedValue([
        makeStaking({ action, amount: Number(expectedStakedAmount) }),
      ])

      const result = await getBlock(context, 5_000_000)

      expect(result.transactions).toHaveLength(1)
      const tx = result.transactions[0]
      expect(tx.hash).toBe('opStaking1')
      expect(tx.failed).toBe(false)
      expect(tx.feesPayer).toBe('tz1Staker')
      expect(tx.fees).toBe(800n)
      expect(tx.operations).toHaveLength(1)
      expect(tx.operations[0]).toEqual({
        type: 'other',
        ledgerOpType: expectedOpType,
        operationType: expectedOpType,
        stakedAmount: expectedStakedAmount,
        counter: 1,
        gasLimit: 3630,
        storageLimit: 0,
      })
    }
  )

  it('marks a staking op as failed but still includes operations with requestedAmount fallback', async () => {
    // TzKT omits `amount` on failed ops; only `requestedAmount` is present
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockStaking.mockResolvedValue([
      makeStaking({ status: 'failed', amount: undefined, requestedAmount: 750_000_000 }),
    ])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions[0].failed).toBe(true)
    expect(result.transactions[0].operations).toHaveLength(1)
    const op = result.transactions[0].operations[0] as Record<string, unknown>
    expect(op.stakedAmount).toBe(750_000_000)
  })

  it('skips a staking op without sender (no operations emitted)', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockStaking.mockResolvedValue([makeStaking({ sender: null })])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions).toHaveLength(1)
    expect(result.transactions[0].operations).toEqual([])
  })

  it('skips a staking op with no hash', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockStaking.mockResolvedValue([
      makeStaking({ hash: undefined as unknown as string }),
      makeStaking({ id: 301, hash: 'opStaking2' }),
    ])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions).toHaveLength(1)
    expect(result.transactions[0].hash).toBe('opStaking2')
  })

  it('merges a staking op into an existing transaction sharing the same hash', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ hash: 'opShared', amount: 1_000_000, bakerFee: 500 }),
    ])
    mockFetchBlockStaking.mockResolvedValue([
      makeStaking({ hash: 'opShared', action: 'stake', amount: 100, bakerFee: 300 }),
    ])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions).toHaveLength(1)
    const tx = result.transactions[0]
    expect(tx.operations.some((op) => op.type === 'transfer')).toBe(true)
    expect(tx.operations.some((op) => op.type === 'other')).toBe(true)
    expect(tx.fees).toBe(800n)
  })

  it('omits delegate field when baker is missing', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockStaking.mockResolvedValue([makeStaking({ baker: null })])

    const result = await getBlock(context, 5_000_000)

    const op = result.transactions[0].operations[0] as Record<string, unknown>
    expect(op).not.toHaveProperty('delegate')
    expect(op.ledgerOpType).toBe('STAKE')
  })
})

// ---------------------------------------------------------------------------
// 8. Origination operations
// ---------------------------------------------------------------------------

describe('origination operations', () => {
  it('produces a BlockTransaction with ledgerOpType ORIGINATION and zero amount for zero-balance origination', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockOriginations.mockResolvedValue([
      makeOrigination({ contractBalance: 0, counter: 42, gasLimit: 3494, storageLimit: 5852 }),
    ])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions).toHaveLength(1)
    const tx = result.transactions[0]
    expect(tx.hash).toBe('opOrigination1')
    expect(tx.fees).toBe(1_069_250n) // 5000 + 1000000 + 64250
    expect(tx.feesPayer).toBe('tz1Deployer')
    expect(tx.failed).toBe(false)
    expect(tx.operations).toHaveLength(1)
    expect(tx.operations[0]).toMatchObject({
      type: 'other',
      address: 'tz1Deployer',
      ledgerOpType: 'ORIGINATION',
      counter: 42,
      gasLimit: 3494,
      storageLimit: 5852,
    })
    expect(tx.operations[0]).not.toHaveProperty('originatedContract')
  })

  it('produces an origination with contractBalance > 0 (no amount field)', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockOriginations.mockResolvedValue([makeOrigination({ contractBalance: 500_000 })])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions).toHaveLength(1)
    const tx = result.transactions[0]
    expect(tx.operations).toHaveLength(1)
    const op = tx.operations[0] as OtherBlockOperation
    expect(op.type).toBe('other')
    expect(op.address).toBe('tz1Deployer')
    expect((op as Record<string, unknown>).ledgerOpType).toBe('ORIGINATION')
    expect(op).not.toHaveProperty('amount')
  })

  it('treats negative contractBalance same as zero (no amount field)', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockOriginations.mockResolvedValue([makeOrigination({ contractBalance: -500 })])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions[0].operations[0]).not.toHaveProperty('amount')
  })

  it('marks a failed origination but still includes operations', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockOriginations.mockResolvedValue([
      makeOrigination({ status: 'failed', contractBalance: 500_000 }),
    ])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions).toHaveLength(1)
    const tx = result.transactions[0]
    expect(tx.failed).toBe(true)
    expect(tx.operations).toHaveLength(1)
    expect(tx.fees).toBe(1_069_250n)
  })

  it('skips an origination with no hash', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockOriginations.mockResolvedValue([makeOrigination({ hash: undefined })])

    const result = await getBlock(context, 5_000_000)
    expect(result.transactions).toHaveLength(0)
  })

  it('returns empty operations when sender is null', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockOriginations.mockResolvedValue([
      makeOrigination({ sender: null, contractBalance: 100_000 }),
    ])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions).toHaveLength(1)
    const tx = result.transactions[0]
    expect(tx.operations).toHaveLength(0)
    expect(tx.feesPayer).toBeUndefined()
  })

  it('attaches token transfers to origination parent via originationId', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockOriginations.mockResolvedValue([
      makeOrigination({ id: 400, hash: 'opOrig1', contractBalance: 0 }),
    ])
    mockFetchBlockTokenTransfers.mockResolvedValue([
      makeTokenTransfer({ transactionId: undefined, originationId: 400, amount: '1000' }),
    ])

    const result = await getBlock(context, 5_000_000)

    // origination tx should contain both the native transfer and the token ops
    const origTx = result.transactions.find((t) => t.hash === 'opOrig1')!
    const tokenOps = origTx.operations.filter(
      (op) => op.type === 'transfer' && 'asset' in op && (op as any).asset.type === 'fa2'
    )
    expect(tokenOps).toHaveLength(2) // from + to
  })

  it('resolves cross-block origination hash for token transfers', async () => {
    // Token transfer at this block has originationId=999 pointing to an origination
    // from a different block. getBlock should batch-fetch the origination to resolve the hash.
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTokenTransfers.mockResolvedValue([
      makeTokenTransfer({ id: 50, transactionId: undefined, originationId: 999, amount: '500' }),
    ])
    mockGetOperationsOrigination.mockResolvedValue([
      { id: 999, hash: 'opCrossBlockOrig', block: 'BLsomeOtherBlock' },
    ])

    const result = await getBlock(context, 5_000_000)

    // The token transfer should use the resolved origination hash, not "token-50"
    expect(mockGetOperationsOrigination).toHaveBeenCalledWith(0, undefined, { 'id.in': '999' })
    const tx = result.transactions.find((t) => t.hash === 'opCrossBlockOrig')!
    expect(tx.operations).toHaveLength(2) // from + to token ops
  })
})

// ---------------------------------------------------------------------------
// 8b. Reveal operations
// ---------------------------------------------------------------------------

describe('reveal operations', () => {
  it('creates a BlockTransaction for a reveal with ledgerOpType REVEAL', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockReveals.mockResolvedValue([
      makeReveal({ counter: 10, gasLimit: 10600, storageLimit: 0, bakerFee: 1420 }),
    ])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions).toHaveLength(1)
    const tx = result.transactions[0]
    expect(tx.hash).toBe('opReveal1')
    expect(tx.failed).toBe(false)
    expect(tx.feesPayer).toBe('tz1Revealer')
    expect(tx.fees).toBe(1420n)
    expect(tx.operations).toHaveLength(1)
    expect(tx.operations[0]).toEqual({
      type: 'other',
      address: 'tz1Revealer',
      ledgerOpType: 'REVEAL',
      counter: 10,
      gasLimit: 10600,
      storageLimit: 0,
    })
  })

  it('merges reveal fee into sibling transaction sharing the same hash', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ hash: 'opBatchedHash', amount: 0, bakerFee: 5115, sender: { address: 'tz1User' } }),
    ])
    mockFetchBlockReveals.mockResolvedValue([
      makeReveal({ hash: 'opBatchedHash', bakerFee: 1420, sender: { address: 'tz1User' } }),
    ])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions).toHaveLength(1)
    const tx = result.transactions[0]
    expect(tx.fees).toBe(6535n) // 5115 + 1420
    expect(tx.operations.some((op) => op.type === 'other')).toBe(true)
    const revealOp = tx.operations.find((op) => op.type === 'other') as Record<string, unknown>
    expect(revealOp).toMatchObject({ ledgerOpType: 'REVEAL' })
  })

  it('marks a reveal as failed but still includes operations', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockReveals.mockResolvedValue([makeReveal({ status: 'failed' })])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions[0].failed).toBe(true)
    expect(result.transactions[0].operations).toHaveLength(1)
  })

  it('skips reveals without a hash', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockReveals.mockResolvedValue([
      makeReveal({ hash: undefined as unknown as string }),
      makeReveal({ id: 501, hash: 'opReveal2' }),
    ])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions).toHaveLength(1)
    expect(result.transactions[0].hash).toBe('opReveal2')
  })

  it('does not add reveal operations to an already-failed sibling transaction', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockResolvedValue([
      makeTx({ hash: 'opBatchedHash', amount: 1_000_000, status: 'failed', bakerFee: 500 }),
    ])
    mockFetchBlockReveals.mockResolvedValue([makeReveal({ hash: 'opBatchedHash', bakerFee: 1420 })])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions).toHaveLength(1)
    const tx = result.transactions[0]
    expect(tx.failed).toBe(true)
    expect(tx.operations).toEqual([])
    expect(tx.fees).toBe(1920n) // 500 + 1420 — fees still accumulate
  })

  it('creates reveal with empty operations when sender is missing', async () => {
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockReveals.mockResolvedValue([makeReveal({ sender: null })])

    const result = await getBlock(context, 5_000_000)

    expect(result.transactions).toHaveLength(1)
    expect(result.transactions[0].operations).toEqual([])
  })
})

// ---------------------------------------------------------------------------
// 9. Network call contract
// ---------------------------------------------------------------------------

describe('network calls', () => {
  it('issues all eight network calls with the correct heights', async () => {
    // Given
    const height = 6_000_000
    mockGetBlockByLevel.mockResolvedValue(makeBlock(height))

    // When
    await getBlock(context, height)

    // Then – getBlockByLevel is called once for the block and once for the parent
    expect(mockGetBlockByLevel).toHaveBeenCalledTimes(2)
    expect(mockGetBlockByLevel).toHaveBeenCalledWith(height)
    expect(mockGetBlockByLevel).toHaveBeenCalledWith(height - 1)
    expect(mockFetchBlockTransactions).toHaveBeenCalledTimes(1)
    expect(mockFetchBlockTransactions).toHaveBeenCalledWith(expect.anything(), height)
    expect(mockFetchBlockTokenTransfers).toHaveBeenCalledTimes(1)
    expect(mockFetchBlockTokenTransfers).toHaveBeenCalledWith(expect.anything(), height)
    expect(mockFetchBlockDelegations).toHaveBeenCalledTimes(1)
    expect(mockFetchBlockDelegations).toHaveBeenCalledWith(expect.anything(), height)
    expect(mockFetchBlockStaking).toHaveBeenCalledTimes(1)
    expect(mockFetchBlockStaking).toHaveBeenCalledWith(expect.anything(), height)
    expect(mockFetchBlockOriginations).toHaveBeenCalledTimes(1)
    expect(mockFetchBlockOriginations).toHaveBeenCalledWith(expect.anything(), height)
    expect(mockFetchBlockReveals).toHaveBeenCalledTimes(1)
    expect(mockFetchBlockReveals).toHaveBeenCalledWith(expect.anything(), height)
  })

  it('propagates errors from getBlockByLevel', async () => {
    // Given
    mockGetBlockByLevel.mockRejectedValue(new Error('block fetch failed'))

    // When / Then
    await expect(getBlock(context, 1_000_000)).rejects.toThrow('block fetch failed')
  })

  it('propagates errors from fetchBlockTransactions', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTransactions.mockRejectedValue(new Error('tx fetch failed'))

    // When / Then
    await expect(getBlock(context, 1_000_000)).rejects.toThrow('tx fetch failed')
  })

  it('propagates errors from fetchBlockTokenTransfers', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockTokenTransfers.mockRejectedValue(new Error('token fetch failed'))

    // When / Then
    await expect(getBlock(context, 1_000_000)).rejects.toThrow('token fetch failed')
  })

  it('propagates errors from fetchBlockDelegations', async () => {
    // Given
    mockGetBlockByLevel.mockResolvedValue(makeBlock())
    mockFetchBlockDelegations.mockRejectedValue(new Error('delegation fetch failed'))

    // When / Then
    await expect(getBlock(context, 1_000_000)).rejects.toThrow('delegation fetch failed')
  })
})
