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

import type { TezosCoinConfig, TezosContext } from '../config'
import { listOperations } from './listOperations'

/**
 * Mainnet integration tests for listOperations with FA2 (USDt) token transfers.
 * Uses a dedicated account holding FA2 USDt (tokenId 0) — see TzKT for current balance.
 *
 * Explorer: https://xtz-tzkt-explorer.api.vault.ledger.com (Ledger vault)
 */

const ADDRESS = 'tz1UD2zz5eFTW2Jy26kBnC3ZkdeazUgeFWST'
/** FA2 USDt contract for this account on mainnet (TzKT). */
const USDT_FA2_CONTRACT = 'KT1XnTn74bUtxHfDtBmm2bGZAQfhPbvKWR8o'
/** Expected assetReference after the LIVE-30344 fix: contract:tokenId. USDt tokenId is 0. */
const USDT_FA2_ASSET_REF = `${USDT_FA2_CONTRACT}:0`
const KT1_ASSET_REF_PATTERN = /^KT1[1-9A-HJ-NP-Za-km-z]+:\d+$/

const mainnetConfig = (): TezosCoinConfig =>
  ({
    status: { type: 'active' },
    baker: { url: 'https://tezos-bakers.api.live.ledger.com' },
    explorer: { url: 'https://xtz-tzkt-explorer.api.vault.ledger.com', maxTxQuery: 100 },
    node: { url: 'https://xtz-node.api.live.ledger.com' },
    fees: {
      minGasLimit: 0,
      minRevealGasLimit: 0,
      minStorageLimit: 0,
      minFees: 0,
      minEstimatedFees: 0,
    },
  }) as TezosCoinConfig

const baseOpts = { sort: 'Descending' as const, minHeight: 0 }

describe('listOperations — mainnet FA2 / convertTokenOperation', () => {
  const context: TezosContext = {
    config: async () => mainnetConfig(),
    logger: () => undefined,
  }

  it('returns at least one FA2 token operation', async () => {
    const [operations] = await listOperations(context, ADDRESS, baseOpts)
    expect(operations.some((op) => op.asset.type === 'fa2')).toBe(true)
  })

  it('maps FA2 token operations via convertTokenOperation (shape)', async () => {
    const [operations] = await listOperations(context, ADDRESS, { ...baseOpts, limit: 200 })
    const fa2 = operations.find((op) => {
      if (op.asset.type !== 'fa2') return false
      return op.asset.assetReference === USDT_FA2_ASSET_REF
    })

    const tokenOp = fa2!
    expect(tokenOp.asset.type).toBe('fa2')
    if (tokenOp.asset.type !== 'fa2') {
      throw new Error('expected FA2 operation')
    }
    const asset = tokenOp.asset

    expect(asset).toMatchObject({
      type: 'fa2',
      assetOwner: ADDRESS,
    })
    expect(asset.assetReference).toMatch(KT1_ASSET_REF_PATTERN)
    expect(asset.assetReference).toBe(USDT_FA2_ASSET_REF)
    const unit = asset.unit!
    expect(typeof unit.magnitude).toBe('number')
    expect(unit.magnitude).toBeGreaterThanOrEqual(0)
    expect(typeof unit.code).toBe('string')
    // TzKT sometimes omits metadata; when decimals are present they should be 6 for USDt.
    if (unit.magnitude > 0) {
      expect(unit.magnitude).toBe(6)
      expect(unit.code).toMatch(/USDt|USDT/i)
    }
    expect(['IN', 'OUT', 'FEES']).toContain(tokenOp.type)
    expect(tokenOp.value).toBeGreaterThanOrEqual(0n)
    expect(tokenOp.tx.hash).toMatch(/^o[1-9A-HJ-NP-Za-km-z]+/)
    expect(tokenOp.tx.block.hash).toMatch(/^B[1-9A-HJ-NP-Za-km-z]+/)
    expect(tokenOp.tx.block.height).toBeGreaterThan(0)
    expect(tokenOp.tx.date).toBeInstanceOf(Date)
    const idParts = tokenOp.id.match(/^(.+)-token-(\d+)$/)
    expect(idParts?.[1]).toBe(tokenOp.tx.hash)
    expect(idParts?.[2]).toMatch(/^\d+$/)
    expect(Number.parseInt(idParts![2], 10)).toBeGreaterThanOrEqual(0)
  })

  it('infers direction: OUT when sending, IN when receiving (FA2)', async () => {
    const [operations] = await listOperations(context, ADDRESS, { ...baseOpts, limit: 200 })
    const fa2Ops = operations.filter((op) => op.asset.type === 'fa2')

    for (const op of fa2Ops) {
      const isSender = op.senders.includes(ADDRESS)
      const isRecipient = op.recipients.includes(ADDRESS)

      if (isSender && !isRecipient) {
        expect(op.type).toBe('OUT')
      }
      if (isRecipient && !isSender) {
        expect(op.type).toBe('IN')
      }
      if (isSender && isRecipient) {
        expect(op.type).toBe('FEES')
      }
    }
  })

  it('attributes fees from parent native transaction when resolvable (FA2)', async () => {
    const [operations] = await listOperations(context, ADDRESS, { ...baseOpts, limit: 200 })
    const fa2Ops = operations.filter((op) => op.asset.type === 'fa2')

    for (const op of fa2Ops) {
      expect(op.tx.fees).toBeGreaterThanOrEqual(0n)
    }

    // Parent tx is joined only when it appears in `getAccountOperations` for this address;
    // otherwise fees stay 0 and parent* details are empty.
    const withParentHint = fa2Ops.filter((op) => {
      const parentSenders = op.details?.parentSenders
      const parentRecipients = op.details?.parentRecipients
      return (
        (Array.isArray(parentSenders) && parentSenders.length > 0) ||
        (Array.isArray(parentRecipients) && parentRecipients.length > 0)
      )
    })
    for (const op of withParentHint) {
      expect(op.tx.fees).toBeGreaterThan(0n)
      if (op.tx.feesPayer !== undefined) {
        expect(op.tx.feesPayer).toMatch(/^tz[123][1-9A-HJ-NP-Za-km-z]+$/)
      }
    }
  })

  it('exposes convertTokenOperation details for FA2', async () => {
    const [operations] = await listOperations(context, ADDRESS, baseOpts)
    const fa2 = operations.find((op) => op.asset.type === 'fa2')

    const conversionDetails = fa2!.details!
    expect(typeof conversionDetails.assetAmount).toBe('string')
    expect(Array.isArray(conversionDetails.assetSenders)).toBe(true)
    expect(Array.isArray(conversionDetails.assetRecipients)).toBe(true)
    expect(Array.isArray(conversionDetails.parentSenders)).toBe(true)
    expect(Array.isArray(conversionDetails.parentRecipients)).toBe(true)
  })

  it('returns a pagination cursor with lastLevel boundary', async () => {
    const [, nextToken] = await listOperations(context, ADDRESS, { ...baseOpts, limit: 3 })
    expect(nextToken.length).toBeGreaterThan(0)

    const parsed: unknown = JSON.parse(nextToken)
    expect(parsed).toEqual(
      expect.objectContaining({
        lastLevel: expect.any(Number),
      })
    )
  })

  it('pagination returns disjoint operation ids across pages', async () => {
    const limit = 3
    const [page1, cursor] = await listOperations(context, ADDRESS, { ...baseOpts, limit })
    expect(cursor.length).toBeGreaterThan(0)

    const [page2] = await listOperations(context, ADDRESS, { ...baseOpts, limit, token: cursor })

    const ids1 = new Set(page1.map((op) => op.id))
    const overlap = page2.filter((op) => ids1.has(op.id))
    expect(overlap).toHaveLength(0)
  })

  it('second page accepts cursor with lastLevel for both streams', async () => {
    const limit = 3
    const [, cursor] = await listOperations(context, ADDRESS, { ...baseOpts, limit })
    expect(cursor.length).toBeGreaterThan(0)

    const parsed: unknown = JSON.parse(cursor)
    expect(parsed).toEqual(
      expect.objectContaining({
        lastLevel: expect.any(Number),
      })
    )

    await expect(
      listOperations(context, ADDRESS, { ...baseOpts, limit, token: cursor })
    ).resolves.toBeDefined()
  })

  it('does not split the same tx.hash across consecutive pages (limit 3)', async () => {
    const limit = 3
    const [page1, cursor] = await listOperations(context, ADDRESS, { ...baseOpts, limit })
    if (cursor.length === 0) {
      return
    }
    const [page2] = await listOperations(context, ADDRESS, { ...baseOpts, limit, token: cursor })
    const hashesPage1 = new Set(page1.map((op) => op.tx.hash))
    const hashesPage2 = new Set(page2.map((op) => op.tx.hash))
    const overlap = [...hashesPage1].filter((h) => hashesPage2.has(h))
    expect(overlap).toHaveLength(0)
  })

  it('does not duplicate operation ids within the first page', async () => {
    const [operations] = await listOperations(context, ADDRESS, { ...baseOpts, limit: 100 })
    const ids = operations.map((op) => op.id)
    expect(new Set(ids).size).toBe(ids.length)
  })
})

// Shadownet test address `tz1dKrT1…` was funded via faucet and put through:
// delegate to TF Test Baker, stake 500 XTZ, unstake 250 XTZ, then
// finalize_unstake (paid by a helper account, after ~2 cycles ≈ 48h).
const SHADOWNET_STAKER = 'tz1dKrT1h6d7wP8fEzMPptG6er7mLLeQjBBY'
const SHADOWNET_BAKER = 'tz3Q67aMz7gSMiQRcW729sXSfuMtkyAHYfqc'

const shadownetConfig = (): TezosCoinConfig =>
  ({
    status: { type: 'active' },
    baker: { url: 'https://tezos-bakers.api.live.ledger.com' },
    explorer: { url: 'https://api.shadownet.tzkt.io', maxTxQuery: 100 },
    node: { url: 'https://rpc.shadownet.teztnets.com' },
    fees: {
      minGasLimit: 0,
      minRevealGasLimit: 0,
      minStorageLimit: 0,
      minFees: 0,
      minEstimatedFees: 0,
    },
  }) as TezosCoinConfig

// To refresh: TzKT `/v1/operations/staking?sender=…` + `/v1/blocks/{level}`.
const EXPECTED_STAKE = {
  id: 'ooyKCZtF84XchfEr5fjiQV5BEEBjKSCjhKW7xyY4o763yj27mLs-96147000524800',
  asset: { type: 'native' },
  tx: {
    hash: 'ooyKCZtF84XchfEr5fjiQV5BEEBjKSCjhKW7xyY4o763yj27mLs',
    fees: 797n,
    feesPayer: SHADOWNET_STAKER,
    block: {
      hash: 'BLa8cr5yFHqozjAzEnkvKQmaDmAxeurBBGvZqdibmzCiYmR6r6L',
      height: 3106279,
      time: new Date('2026-04-28T20:51:21Z'),
    },
    date: new Date('2026-04-28T20:51:21Z'),
    failed: false,
  },
  type: 'STAKE',
  value: 500_000_000n,
  senders: [SHADOWNET_STAKER],
  recipients: [SHADOWNET_BAKER],
  details: {
    counter: 14416505,
    gasLimit: 3630,
    storageLimit: 0,
    ledgerOpType: 'STAKE',
  },
}

const EXPECTED_UNSTAKE = {
  id: 'ooXrdBW4aY3613YkmsX4uzdidqUTM2Gw7cFZ7aU6irwQ6TmLeFS-96148169687040',
  asset: { type: 'native' },
  tx: {
    hash: 'ooXrdBW4aY3613YkmsX4uzdidqUTM2Gw7cFZ7aU6irwQ6TmLeFS',
    fees: 858n,
    feesPayer: SHADOWNET_STAKER,
    block: {
      hash: 'BLMaHTGtBfh7ZM2wk5rpFHQhNtx5LC7YMdYrnokzBcosrpgqNAe',
      height: 3106307,
      time: new Date('2026-04-28T20:54:09Z'),
    },
    date: new Date('2026-04-28T20:54:09Z'),
    failed: false,
  },
  type: 'UNSTAKE',
  value: 250_000_000n,
  senders: [SHADOWNET_STAKER],
  recipients: [SHADOWNET_BAKER],
  details: {
    counter: 14416506,
    gasLimit: 4250,
    storageLimit: 0,
    ledgerOpType: 'UNSTAKE',
  },
}

const FINALIZE_FEES_PAYER = 'tz1i92Eptw7UZ8JSb8j8jBFJ9Poa4TTnSQwZ'
const EXPECTED_FINALIZE_UNSTAKE = {
  id: 'oodrjyZyoyccgoE24BbMsxfSY4JnP6nrZpmw2xqzurQb7QW3HBD-98214102433792',
  asset: { type: 'native' },
  tx: {
    hash: 'oodrjyZyoyccgoE24BbMsxfSY4JnP6nrZpmw2xqzurQb7QW3HBD',
    fees: 492n,
    feesPayer: FINALIZE_FEES_PAYER,
    block: {
      hash: 'BLvjbzKJ5cQfmncDt3CiKcuKpk87pP34t6rmm6FWE9iCFbJb1Xp',
      height: 3153614,
      time: new Date('2026-05-02T03:50:54Z'),
    },
    date: new Date('2026-05-02T03:50:54Z'),
    failed: false,
  },
  type: 'FINALIZE_UNSTAKE',
  value: 250_000_000n,
  senders: [SHADOWNET_BAKER],
  recipients: [SHADOWNET_STAKER],
  details: {
    counter: 801846,
    gasLimit: 2169,
    storageLimit: 0,
    ledgerOpType: 'FINALIZE_UNSTAKE',
  },
}

describe('listOperations — Shadownet Paris staking ops', () => {
  const context: TezosContext = {
    config: async () => shadownetConfig(),
    logger: () => undefined,
  }

  it.each([
    ['STAKE', EXPECTED_STAKE],
    ['UNSTAKE', EXPECTED_UNSTAKE],
    ['FINALIZE_UNSTAKE', EXPECTED_FINALIZE_UNSTAKE],
  ])('contains a %s op matching every field of the pinned fixture', async (type, expected) => {
    const [operations] = await listOperations(context, SHADOWNET_STAKER, {
      ...baseOpts,
      limit: 100,
    })
    const match = operations.find((op) => op.type === type)
    if (!match) throw new Error(`No ${type} op found at ${SHADOWNET_STAKER}`)

    expect(match).toEqual(expected)
  })
})

describe('listOperations — mainnet origination ops', () => {
  // This account deployed 4 contracts — origination ops were previously silently dropped.
  const ORIGINATOR = 'tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb'

  const context: TezosContext = {
    config: async () => mainnetConfig(),
    logger: () => undefined,
  }

  it('surfaces origination operations with correct fees and ledgerOpType', async () => {
    // Fetch with a high limit to get all ops in one page (account has ~234 ops)
    const [operations] = await listOperations(context, ORIGINATOR, {
      sort: 'Ascending',
      minHeight: 0,
      limit: 500,
    })

    const originations = operations.filter((op) => op.details?.ledgerOpType === 'ORIGINATION')
    expect(originations.length).toBeGreaterThanOrEqual(4)
    for (const op of originations) {
      expect(op.type).toBe('FEES')
      expect(op.value).toBe(0n)
      expect(op.tx.fees).toBeGreaterThan(0n)
      expect(op.tx.feesPayer).toBe(ORIGINATOR)
      expect(op.senders).toContain(ORIGINATOR)
    }

    // Total origination fees should be at least the known historical balance gap (~5.48M mutez)
    const totalFees = originations.reduce((sum, op) => sum + op.tx.fees, 0n)
    expect(totalFees).toBeGreaterThanOrEqual(5_480_510n)
  })

  it('native ops (including internal sub-tx fees) sum to the on-chain balance', async () => {
    // Paginate until all ops are fetched
    let allOps: Awaited<ReturnType<typeof listOperations>>[0] = []
    let token: string | undefined
    do {
      const [ops, next] = await listOperations(context, ORIGINATOR, {
        sort: 'Ascending',
        minHeight: 0,
        limit: 200,
        token,
      })
      allOps = allOps.concat(ops)
      token = next || undefined
    } while (token)

    const nativeOps = allOps.filter((op) => op.asset.type === 'native')

    let balance = 0n
    for (const op of nativeOps) {
      if (op.type === 'IN') balance += op.value
      else if (op.type === 'OUT') balance -= op.value
    }

    // Subtract fees only when this account is the fee payer
    for (const op of nativeOps) {
      if (op.tx.feesPayer === ORIGINATOR && op.tx.fees > 0n) {
        balance -= op.tx.fees
      }
    }

    const explorerUrl = mainnetConfig().explorer.url
    const resp = await fetch(`${explorerUrl}/v1/accounts/${ORIGINATOR}`)
    if (!resp.ok) throw new Error(`TzKT returned ${resp.status}: ${await resp.text()}`)
    const account = await resp.json()

    expect(balance).toEqual(BigInt(account.balance))
  }, 60_000)
})

/**
 * Multi-asset FA2 integration tests (LIVE-29210).
 * Uses tz1ZB8hpQJZdQeEPc3uG2cL1NXwLTPkqeAD6 which holds multiple tokens
 * on the Wrapped Tokens Contract (KT18fp5rcTW7mbWDmzFwjLDUhs5MeJmagDSZ).
 */
const MULTI_ASSET_ADDRESS = 'tz1ZB8hpQJZdQeEPc3uG2cL1NXwLTPkqeAD6'
const WRAPPED_CONTRACT = 'KT18fp5rcTW7mbWDmzFwjLDUhs5MeJmagDSZ'

describe('listOperations — mainnet multi-asset FA2 (LIVE-29210)', () => {
  const context: TezosContext = {
    config: async () => mainnetConfig(),
    logger: () => undefined,
  }

  it('returns a known multi-asset FA2 operation with tokenId=11', async () => {
    const [operations] = await listOperations(context, MULTI_ASSET_ADDRESS, {
      ...baseOpts,
      limit: 200,
    })

    // Known on-chain transfer: tokenId=11 (wMATIC), OUT to KT1V5X...
    const op = operations.find(
      (op) =>
        'assetReference' in op.asset &&
        op.asset.assetReference === `${WRAPPED_CONTRACT}:11` &&
        op.tx.hash === 'ooMtaw5H7dtrgAyW5ky4jFzhQjayWn6La2h3BadHvZd56p6jm9g'
    )!

    expect(op.type).toBe('OUT')
    expect(op.asset.type).toBe('fa2')
    expect(op.value).toBe(18423705864886566n)
    expect(op.senders).toEqual([MULTI_ASSET_ADDRESS])
    expect(op.recipients).toEqual(['KT1V5XKmeypanMS9pR65REpqmVejWBZURuuT'])
  })
})
