import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Transaction } from '@mysten/sui/transactions';
import {
  aggregateClaimableRewards,
  buildClaimRewardsTx,
  getPendingRewards,
  addClaimRewardsToTx,
} from './navi.js';
import { T2000Error } from '../errors.js';

vi.mock('@naviprotocol/lending', async (importActual) => {
  const actual = await importActual<typeof import('@naviprotocol/lending')>();
  return {
    ...actual,
    getUserAvailableLendingRewards: vi.fn(),
    claimLendingRewardsPTB: vi.fn().mockResolvedValue([]),
  };
});

import {
  getUserAvailableLendingRewards,
  claimLendingRewardsPTB,
} from '@naviprotocol/lending';

const RATE_DECIMALS = 27;
const MIN_HEALTH_FACTOR = 1.5;

function rateToApy(rawRate: string): number {
  if (!rawRate || rawRate === '0') return 0;
  return Number(BigInt(rawRate)) / 10 ** RATE_DECIMALS * 100;
}

describe('navi', () => {
  describe('rateToApy', () => {
    it('converts raw rate to APY percentage', () => {
      const rawRate5Percent = (BigInt(5) * BigInt(10 ** RATE_DECIMALS) / 100n).toString();
      expect(rateToApy(rawRate5Percent)).toBeCloseTo(5.0, 1);
    });

    it('returns 0 for zero rate', () => {
      expect(rateToApy('0')).toBe(0);
    });

    it('returns 0 for empty rate', () => {
      expect(rateToApy('')).toBe(0);
    });

    it('handles small fractional rates', () => {
      const rawRate = (BigInt(25) * BigInt(10 ** (RATE_DECIMALS - 1)) / 100n).toString();
      expect(rateToApy(rawRate)).toBeCloseTo(2.5, 1);
    });
  });

  describe('health factor math', () => {
    it('returns Infinity when no borrows', () => {
      const supplied = 100;
      const borrowed = 0;
      const hf = borrowed > 0 ? supplied / borrowed : Infinity;
      expect(hf).toBe(Infinity);
    });

    it('calculates health factor correctly', () => {
      const supplied = 100;
      const borrowed = 50;
      const hf = supplied / borrowed;
      expect(hf).toBe(2.0);
    });

    it('detects unhealthy position', () => {
      const supplied = 100;
      const borrowed = 80;
      const hf = supplied / borrowed;
      expect(hf).toBe(1.25);
      expect(hf).toBeLessThan(1.5);
    });
  });

  describe('maxWithdraw calculation', () => {
    function computeMaxWithdraw(supplied: number, borrowed: number, ltv = 0.75) {
      let maxAmount: number;
      if (borrowed === 0) {
        maxAmount = supplied;
      } else {
        maxAmount = Math.max(0, supplied - (borrowed * MIN_HEALTH_FACTOR / ltv));
      }
      const remainingSupply = supplied - maxAmount;
      const hfAfter = borrowed > 0 ? (remainingSupply * ltv) / borrowed : Infinity;
      const currentHF = borrowed > 0 ? supplied / borrowed : Infinity;
      return { maxAmount, healthFactorAfter: hfAfter, currentHF };
    }

    it('returns full amount when no borrows', () => {
      const r = computeMaxWithdraw(100, 0);
      expect(r.maxAmount).toBe(100);
      expect(r.healthFactorAfter).toBe(Infinity);
      expect(r.currentHF).toBe(Infinity);
    });

    it('limits withdrawal to maintain health factor', () => {
      const r = computeMaxWithdraw(100, 30);
      expect(r.maxAmount).toBe(40);
      expect(r.healthFactorAfter).toBe(1.5);
      expect(r.currentHF).toBeCloseTo(3.33, 1);
    });

    it('returns 0 when fully borrowed', () => {
      const r = computeMaxWithdraw(100, 60);
      expect(r.maxAmount).toBe(0);
      expect(r.currentHF).toBeCloseTo(1.67, 1);
    });

    it('handles custom ltv', () => {
      const r = computeMaxWithdraw(100, 20, 0.8);
      expect(r.maxAmount).toBeCloseTo(62.5, 1);
    });

    it('healthFactorAfter includes ltv multiplier', () => {
      const r = computeMaxWithdraw(200, 50, 0.75);
      expect(r.maxAmount).toBe(100);
      const remaining = 200 - 100;
      expect(r.healthFactorAfter).toBeCloseTo((remaining * 0.75) / 50, 5);
    });
  });

  describe('maxBorrow calculation', () => {
    function computeMaxBorrow(supplied: number, borrowed: number, ltv = 0.75) {
      const maxAmount = Math.max(0, supplied * ltv / MIN_HEALTH_FACTOR - borrowed);
      const currentHF = borrowed > 0 ? supplied / borrowed : Infinity;
      return { maxAmount, healthFactorAfter: MIN_HEALTH_FACTOR, currentHF };
    }

    it('calculates max borrow from supply', () => {
      const r = computeMaxBorrow(100, 0);
      expect(r.maxAmount).toBe(50);
      expect(r.healthFactorAfter).toBe(1.5);
      expect(r.currentHF).toBe(Infinity);
    });

    it('accounts for existing borrows', () => {
      const r = computeMaxBorrow(100, 20);
      expect(r.maxAmount).toBe(30);
      expect(r.currentHF).toBe(5);
    });

    it('returns 0 when at limit', () => {
      const r = computeMaxBorrow(100, 50);
      expect(r.maxAmount).toBe(0);
      expect(r.currentHF).toBe(2);
    });

    it('handles custom ltv', () => {
      const r = computeMaxBorrow(100, 0, 0.8);
      expect(r.maxAmount).toBeCloseTo(53.33, 1);
    });
  });

  describe('position filtering', () => {
    it('identifies USDC positions by symbol', () => {
      const pools = [
        { symbol: 'USDC', coinType: '0x...::usdc::USDC' },
        { symbol: 'SUI', coinType: '0x2::sui::SUI' },
      ];
      const usdc = pools.find(p => p.symbol === 'USDC');
      expect(usdc).toBeDefined();
      expect(usdc?.coinType).toContain('usdc');
    });

    it('identifies USDC positions by coinType fallback', () => {
      const pools = [
        { symbol: 'USD Coin', coinType: '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC' },
      ];
      const usdc = pools.find(p => p.symbol === 'USDC' || p.coinType.toLowerCase().includes('usdc'));
      expect(usdc).toBeDefined();
    });
  });

  describe('amount conversions', () => {
    it('converts USDC raw to human with 6 decimals', () => {
      const raw = 5_000_000;
      const human = raw / 10 ** 6;
      expect(human).toBe(5.0);
    });

    it('converts SUI raw to human with 9 decimals', () => {
      const raw = 1_500_000_000;
      const human = raw / 10 ** 9;
      expect(human).toBe(1.5);
    });

    it('filters out dust positions', () => {
      const positions = [
        { amount: 5.23, type: 'save' },
        { amount: 0.00001, type: 'save' },
        { amount: 0, type: 'borrow' },
      ];
      const active = positions.filter(p => p.amount > 0.0001);
      expect(active).toHaveLength(1);
      expect(active[0].amount).toBe(5.23);
    });
  });

  describe('gas cost extraction', () => {
    function extractGasCost(effects: { gasUsed?: { computationCost: string; storageCost: string; storageRebate: string } } | null): number {
      if (!effects?.gasUsed) return 0;
      return Math.abs(
        (Number(effects.gasUsed.computationCost) +
          Number(effects.gasUsed.storageCost) -
          Number(effects.gasUsed.storageRebate)) /
        1e9
      );
    }

    it('calculates gas cost from effects', () => {
      const effects = {
        gasUsed: {
          computationCost: '1000000',
          storageCost: '2000000',
          storageRebate: '500000',
        },
      };
      expect(extractGasCost(effects)).toBeCloseTo(0.0025, 4);
    });

    it('returns positive value when rebate exceeds cost', () => {
      const effects = {
        gasUsed: {
          computationCost: '500000',
          storageCost: '200000',
          storageRebate: '1500000',
        },
      };
      expect(extractGasCost(effects)).toBeCloseTo(0.0008, 4);
      expect(extractGasCost(effects)).toBeGreaterThan(0);
    });

    it('returns 0 for null effects', () => {
      expect(extractGasCost(null)).toBe(0);
    });

    it('returns 0 for missing gasUsed', () => {
      expect(extractGasCost({} as never)).toBe(0);
    });
  });

  describe('NAVI balance decimals', () => {
    const NAVI_BALANCE_DECIMALS = 9;

    function naviStorageDecimals(poolId: number, tokenDecimals: number): number {
      if (poolId <= 10) return NAVI_BALANCE_DECIMALS;
      return tokenDecimals;
    }

    function compoundBalance(rawBalance: bigint, currentIndex: string, pool?: { id: number; token: { decimals: number } }): number {
      if (!rawBalance || !currentIndex || currentIndex === '0') return 0;
      const scale = BigInt('1' + '0'.repeat(RATE_DECIMALS));
      const half = scale / 2n;
      const result = (rawBalance * BigInt(currentIndex) + half) / scale;
      const decimals = pool ? naviStorageDecimals(pool.id, pool.token.decimals) : NAVI_BALANCE_DECIMALS;
      return Number(result) / 10 ** decimals;
    }

    it('parses USDC supply balance with 9 decimals', () => {
      const supplyBalance = 2_000_000_289;
      const usdc = supplyBalance / 10 ** NAVI_BALANCE_DECIMALS;
      expect(usdc).toBeCloseTo(2.0, 1);
    });

    it('parses USDC borrow balance with 9 decimals', () => {
      const borrowBalance = 500_000_000;
      const usdc = borrowBalance / 10 ** NAVI_BALANCE_DECIMALS;
      expect(usdc).toBeCloseTo(0.5, 1);
    });

    it('treats near-zero NAVI balance as dust', () => {
      const dustBalance = 1155;
      const usdc = dustBalance / 10 ** NAVI_BALANCE_DECIMALS;
      expect(usdc).toBeLessThan(0.0001);
    });

    describe('naviStorageDecimals', () => {
      it('returns 9 for original pools (id <= 10)', () => {
        expect(naviStorageDecimals(0, 9)).toBe(9);  // SUI
        expect(naviStorageDecimals(1, 6)).toBe(9);  // wUSDC
        expect(naviStorageDecimals(10, 6)).toBe(9); // USDC
      });

      it('returns native token decimals for newer pools (id >= 11)', () => {
        expect(naviStorageDecimals(11, 8)).toBe(8);  // suiETH
        expect(naviStorageDecimals(19, 6)).toBe(6);  // suiUSDT
        expect(naviStorageDecimals(33, 6)).toBe(6);  // suiUSDe
        expect(naviStorageDecimals(34, 6)).toBe(6);  // USDsui
      });
    });

    describe('compoundBalance with pool-aware decimals', () => {
      const index1x = BigInt('1' + '0'.repeat(RATE_DECIMALS));

      it('old pool USDC (id=10, 6 dec) uses 9 storage decimals', () => {
        const raw = 5_000_000_000n;
        const pool = { id: 10, token: { decimals: 6 } };
        const result = compoundBalance(raw, index1x.toString(), pool);
        expect(result).toBeCloseTo(5.0, 1);
      });

      it('new pool suiUSDT (id=19, 6 dec) uses native 6 decimals', () => {
        const raw = 1_000_000n;
        const pool = { id: 19, token: { decimals: 6 } };
        const result = compoundBalance(raw, index1x.toString(), pool);
        expect(result).toBeCloseTo(1.0, 1);
      });

      it('new pool suiETH (id=11, 8 dec) uses native 8 decimals', () => {
        const raw = 100_000_000n;
        const pool = { id: 11, token: { decimals: 8 } };
        const result = compoundBalance(raw, index1x.toString(), pool);
        expect(result).toBeCloseTo(1.0, 1);
      });

      it('falls back to 9 decimals when pool is not provided', () => {
        const raw = 5_000_000_000n;
        const result = compoundBalance(raw, index1x.toString());
        expect(result).toBeCloseTo(5.0, 1);
      });

      it('returns 0 for zero raw balance', () => {
        const pool = { id: 19, token: { decimals: 6 } };
        expect(compoundBalance(0n, index1x.toString(), pool)).toBe(0);
      });

      it('returns 0 for zero index', () => {
        const pool = { id: 19, token: { decimals: 6 } };
        expect(compoundBalance(1_000_000n, '0', pool)).toBe(0);
      });

      it('USDT with 1e9 divisor would give wrong value (verifies bug is gone)', () => {
        const raw = 927_395n;
        const index = (BigInt('1088') * BigInt('1' + '0'.repeat(RATE_DECIMALS - 3))).toString();
        const wrongResult = compoundBalance(raw, index);
        const correctResult = compoundBalance(raw, index, { id: 19, token: { decimals: 6 } });
        expect(wrongResult).toBeLessThan(0.01);
        expect(correctResult).toBeGreaterThan(0.5);
      });
    });
  });

  describe('resolvePoolSymbol (coin type → internal key)', () => {
    const SUPPORTED_ASSETS: Record<string, { type: string }> = {
      USDC: { type: '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC' },
      USDT: { type: '0x375f70cf2ae4c00bf37117d0c85a2c71545e6ee05c4a5c7d282cd66a4504b068::usdt::USDT' },
      USDe: { type: '0x41d587e5336f1c86cad50d38a7136db99333bb9bda91cea4ba69115defeb1402::sui_usde::SUI_USDE' },
      USDsui: { type: '0x44f838219cf67b058f3b37907b655f226153c18e33dfcd0da559a844fea9b1c1::usdsui::USDSUI' },
      SUI: { type: '0x2::sui::SUI' },
    };

    function matchesCoinType(poolType: string, targetType: string): boolean {
      const poolSuffix = poolType.split('::').slice(1).join('::').toLowerCase();
      const targetSuffix = targetType.split('::').slice(1).join('::').toLowerCase();
      return poolSuffix === targetSuffix;
    }

    function resolvePoolSymbol(pool: { suiCoinType?: string; coinType?: string; token?: { symbol: string } }): string {
      const coinType = pool.suiCoinType || pool.coinType || '';
      for (const [key, info] of Object.entries(SUPPORTED_ASSETS)) {
        if (matchesCoinType(coinType, info.type)) return key;
      }
      return pool.token?.symbol ?? 'UNKNOWN';
    }

    it('maps NAVI USDC pool to "USDC"', () => {
      expect(resolvePoolSymbol({
        suiCoinType: '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC',
        token: { symbol: 'USDC' },
      })).toBe('USDC');
    });

    it('maps NAVI suiUSDT pool to "USDT" (not "suiUSDT")', () => {
      expect(resolvePoolSymbol({
        suiCoinType: '0x375f70cf2ae4c00bf37117d0c85a2c71545e6ee05c4a5c7d282cd66a4504b068::usdt::USDT',
        token: { symbol: 'suiUSDT' },
      })).toBe('USDT');
    });

    it('maps NAVI suiUSDe pool to "USDe" (not "suiUSDe")', () => {
      expect(resolvePoolSymbol({
        suiCoinType: '0x41d587e5336f1c86cad50d38a7136db99333bb9bda91cea4ba69115defeb1402::sui_usde::SUI_USDE',
        token: { symbol: 'suiUSDe' },
      })).toBe('USDe');
    });

    it('maps NAVI USDSUI pool to "USDsui" (not "USDSUI")', () => {
      expect(resolvePoolSymbol({
        suiCoinType: '0x44f838219cf67b058f3b37907b655f226153c18e33dfcd0da559a844fea9b1c1::usdsui::USDSUI',
        token: { symbol: 'USDSUI' },
      })).toBe('USDsui');
    });

    it('falls back to token.symbol for unknown coin types', () => {
      expect(resolvePoolSymbol({
        suiCoinType: '0xabc::some_coin::SOME_COIN',
        token: { symbol: 'WEIRD' },
      })).toBe('WEIRD');
    });

    it('works with coinType field (no 0x prefix) when suiCoinType missing', () => {
      expect(resolvePoolSymbol({
        coinType: '375f70cf2ae4c00bf37117d0c85a2c71545e6ee05c4a5c7d282cd66a4504b068::usdt::USDT',
        token: { symbol: 'suiUSDT' },
      })).toBe('USDT');
    });
  });

  // Regression coverage for the bug where claimRewards() reported
  // "Claimed $0.00" / "no pending rewards" even when the on-chain tx
  // successfully credited a reward token to the wallet. Root cause was
  // the adapter stubbing every PendingReward as { symbol: 'REWARD',
  // amount: 0, estimatedValueUsd: 0 } and discarding the rewardCoinType
  // / userClaimableReward fields it had on hand.
  describe('aggregateClaimableRewards', () => {
    const VSUI = '0x549e8b69270defbfafd4f94e17ec44cdbdd99820b33bda2278dea3b9a32d3f55::cert::CERT';
    const NS = '0xbc3a676894871284b3ccfb2eec66f428612000e2a6e6d23f592ce8833c27c973::coin::COIN';

    it('returns one entry per reward coin type with real symbol + amount', () => {
      const result = aggregateClaimableRewards([
        { userClaimableReward: 0.01649, rewardCoinType: VSUI, assetId: 5 },
      ]);

      expect(result).toEqual([
        {
          protocol: 'navi',
          asset: '5',
          coinType: VSUI,
          // [S.118 follow-up 2026-05-08] vSUI now resolves via the canonical
          // token registry (`getCoinMeta(coinType).symbol`) instead of
          // returning the raw struct name 'CERT' — Volo's vSUI cert had a
          // misleading on-chain struct name that the LLM previously
          // surfaced verbatim.
          symbol: 'vSUI',
          amount: 0.01649,
          estimatedValueUsd: 0,
        },
      ]);
    });

    it('aggregates same coin type across multiple pools into a single entry', () => {
      const result = aggregateClaimableRewards([
        { userClaimableReward: 0.01, rewardCoinType: VSUI, assetId: 0 },
        { userClaimableReward: 0.0065, rewardCoinType: VSUI, assetId: 5 },
      ]);

      expect(result).toHaveLength(1);
      expect(result[0]?.coinType).toBe(VSUI);
      expect(result[0]?.amount).toBeCloseTo(0.0165, 6);
    });

    it('keeps separate entries for different reward coin types', () => {
      const result = aggregateClaimableRewards([
        { userClaimableReward: 0.01, rewardCoinType: VSUI, assetId: 0 },
        { userClaimableReward: 12.3, rewardCoinType: NS, assetId: 11 },
      ]);

      expect(result).toHaveLength(2);
      expect(result.map((r) => r.coinType).sort()).toEqual([VSUI, NS].sort());
    });

    it('skips zero / negative / NaN amounts so dust does not pollute the list', () => {
      const result = aggregateClaimableRewards([
        { userClaimableReward: 0, rewardCoinType: VSUI },
        { userClaimableReward: -1, rewardCoinType: VSUI },
        { userClaimableReward: Number.NaN as unknown as number, rewardCoinType: VSUI },
      ]);
      expect(result).toEqual([]);
    });

    it('ignores rows missing rewardCoinType', () => {
      const result = aggregateClaimableRewards([
        { userClaimableReward: 1, rewardCoinType: '' },
      ]);
      expect(result).toEqual([]);
    });

    it('parses string-encoded amounts from the SDK', () => {
      const result = aggregateClaimableRewards([
        { userClaimableReward: '0.025' as unknown as number, rewardCoinType: VSUI },
      ]);
      expect(result[0]?.amount).toBeCloseTo(0.025, 6);
    });

    it('never returns the legacy stub fields (asset:"", symbol:"REWARD", amount:0)', () => {
      const result = aggregateClaimableRewards([
        { userClaimableReward: 0.5, rewardCoinType: VSUI, assetId: 5 },
      ]);
      expect(result[0]?.symbol).not.toBe('REWARD');
      expect(result[0]?.coinType).not.toBe('');
      expect(result[0]?.amount).not.toBe(0);
    });
  });

  describe('buildClaimRewardsTx (SPEC 7 P2.2.2 standalone builder)', () => {
    const VSUI = '0x549e8b69270defbfafd4f94e17ec44cdbdd99820b33bda2278dea3b9a32d3f55::cert::CERT';
    const VALID_ADDRESS = '0x' + 'a'.repeat(64);
    const fakeClient = {} as unknown as Parameters<typeof buildClaimRewardsTx>[0];

    beforeEach(() => {
      vi.mocked(getUserAvailableLendingRewards).mockReset();
      vi.mocked(claimLendingRewardsPTB).mockReset().mockResolvedValue([]);
    });

    it('returns { tx, rewards } shape with sender set on tx', async () => {
      vi.mocked(getUserAvailableLendingRewards).mockResolvedValue([]);
      const result = await buildClaimRewardsTx(fakeClient, VALID_ADDRESS);

      expect(result.tx).toBeInstanceOf(Transaction);
      expect(result.tx.getData().sender).toBe(VALID_ADDRESS);
      expect(Array.isArray(result.rewards)).toBe(true);
    });

    it('returns empty rewards + empty tx when no claimable rewards exist', async () => {
      vi.mocked(getUserAvailableLendingRewards).mockResolvedValue([]);
      const result = await buildClaimRewardsTx(fakeClient, VALID_ADDRESS);

      expect(result.rewards).toEqual([]);
      expect(result.tx.getData().commands).toEqual([]);
      expect(claimLendingRewardsPTB).not.toHaveBeenCalled();
    });

    it('returns empty rewards when only zero-amount rewards exist (filtered)', async () => {
      vi.mocked(getUserAvailableLendingRewards).mockResolvedValue([
        { userClaimableReward: 0, rewardCoinType: VSUI, assetId: 5 } as never,
      ]);
      const result = await buildClaimRewardsTx(fakeClient, VALID_ADDRESS);

      expect(result.rewards).toEqual([]);
      expect(claimLendingRewardsPTB).not.toHaveBeenCalled();
    });

    it('returns aggregated rewards + appends PTB calls when claimable rewards exist', async () => {
      vi.mocked(getUserAvailableLendingRewards).mockResolvedValue([
        { userClaimableReward: 0.0165, rewardCoinType: VSUI, assetId: 5 } as never,
      ]);
      const result = await buildClaimRewardsTx(fakeClient, VALID_ADDRESS);

      expect(result.rewards).toHaveLength(1);
      expect(result.rewards[0]?.amount).toBeCloseTo(0.0165, 6);
      expect(result.rewards[0]?.coinType).toBe(VSUI);
      expect(claimLendingRewardsPTB).toHaveBeenCalledTimes(1);
      expect(claimLendingRewardsPTB).toHaveBeenCalledWith(
        result.tx,
        expect.any(Array),
        expect.objectContaining({
          env: 'prod',
          customCoinReceive: { type: 'transfer', transfer: VALID_ADDRESS },
        }),
      );
    });

    it('S18-F20: throws PROTOCOL_UNAVAILABLE on NAVI SDK error (no longer silently empty)', async () => {
      // Pre-S18-F20 this returned `{ rewards: [], tx }` and the engine
      // narrated "no pending rewards" — a false negative when NAVI was
      // actually degraded. Now the typed throw lets the engine tool
      // surface "NAVI is degraded right now, try again in a moment."
      vi.mocked(getUserAvailableLendingRewards).mockRejectedValue(new Error('Network down'));
      await expect(buildClaimRewardsTx(fakeClient, VALID_ADDRESS)).rejects.toThrow(T2000Error);
      await expect(buildClaimRewardsTx(fakeClient, VALID_ADDRESS)).rejects.toMatchObject({
        code: 'PROTOCOL_UNAVAILABLE',
        retryable: true,
      });
    });
  });

  // [S18-F20 contract test, hardened in S.118 follow-up 2026-05-08]
  // The harness has two readers for NAVI rewards — `getPendingRewards`
  // (read-only, called by `pending_rewards` tool) and `addClaimRewardsToTx`
  // (PTB-builder, called inside claim flows). Both NOW go through the
  // SAME source (`userClaimableReward` filter) and the SAME aggregator
  // (`aggregateClaimableRewards`), which means the contract is true
  // structural equality (not just per-coinType total tolerance).
  //
  // Pre-S.118-follow-up `getPendingRewards` iterated `summaryLendingRewards.available`
  // — a different NAVI aggregation that produced row-structure AND
  // per-coinType-total drift on real mainnet data (smoke against funkii's
  // wallet returned 2 rows here vs 1 row from `addClaimRewardsToTx` for
  // the same vSUI cert across two NAVI pools, with totals diverging by
  // ~1.36e-7). Routing both through the same source removes the drift class.
  describe('getPendingRewards ≡ addClaimRewardsToTx contract (S18-F20)', () => {
    const VSUI = '0x549e8b69270defbfafd4f94e17ec44cdbdd99820b33bda2278dea3b9a32d3f55::cert::CERT';
    const NAVX = '0xa99b8952d4f7d947ea77fe0ecdcc9e5fc0bcab2841d6e2a5aa00c3044e5544b5::navx::NAVX';
    const NS = '0xbc3a676894871284b3ccfb2eec66f428612000e2a6e6d23f592ce8833c27c973::coin::COIN';
    const VALID_ADDRESS = '0x' + 'a'.repeat(64);
    const fakeClient = {} as unknown as Parameters<typeof getPendingRewards>[0];

    beforeEach(() => {
      vi.mocked(getUserAvailableLendingRewards).mockReset();
      vi.mocked(claimLendingRewardsPTB).mockReset().mockResolvedValue([]);
    });

    function totalsByCoinType(rewards: Array<{ coinType: string; amount: number }>): Record<string, number> {
      const out: Record<string, number> = {};
      for (const r of rewards) {
        out[r.coinType] = (out[r.coinType] ?? 0) + r.amount;
      }
      return out;
    }

    // [S.118 follow-up] Helper: normalize a reward row to its identity-relevant
    // fields so structural equality works regardless of per-row asset id (which
    // can legitimately differ — getPendingRewards picks the first asset id seen,
    // addClaimRewardsToTx picks per-claimable-row id; both are arbitrary
    // labels for an aggregated row).
    function structuralView(rewards: Array<{ coinType: string; symbol: string; amount: number }>) {
      return rewards
        .map((r) => ({ coinType: r.coinType, symbol: r.symbol, amount: r.amount }))
        .sort((a, b) => a.coinType.localeCompare(b.coinType));
    }

    it('row structure + totals match for a single-pool single-coin reward', async () => {
      const mockRewards = [
        { userClaimableReward: 0.0165, rewardCoinType: VSUI, assetId: 5 },
      ] as never;
      vi.mocked(getUserAvailableLendingRewards).mockResolvedValue(mockRewards);

      const pending = await getPendingRewards(fakeClient, VALID_ADDRESS);
      const tx = new Transaction();
      tx.setSender(VALID_ADDRESS);
      const claimable = await addClaimRewardsToTx(tx, fakeClient, VALID_ADDRESS);

      expect(totalsByCoinType(pending)).toEqual(totalsByCoinType(claimable));
      expect(structuralView(pending)).toEqual(structuralView(claimable));
    });

    it('row structure + totals match for multi-pool same-coin (aggregation case)', async () => {
      const mockRewards = [
        { userClaimableReward: 0.01, rewardCoinType: VSUI, assetId: 0 },
        { userClaimableReward: 0.0065, rewardCoinType: VSUI, assetId: 5 },
      ] as never;
      vi.mocked(getUserAvailableLendingRewards).mockResolvedValue(mockRewards);

      const pending = await getPendingRewards(fakeClient, VALID_ADDRESS);
      const tx = new Transaction();
      tx.setSender(VALID_ADDRESS);
      const claimable = await addClaimRewardsToTx(tx, fakeClient, VALID_ADDRESS);

      expect(totalsByCoinType(pending)).toEqual(totalsByCoinType(claimable));
      expect(structuralView(pending)).toEqual(structuralView(claimable));
      expect(pending).toHaveLength(1);
      expect(claimable).toHaveLength(1);
    });

    it('row structure + totals match for cross-coin rewards (vSUI + NAVX + NS)', async () => {
      const mockRewards = [
        { userClaimableReward: 0.0165, rewardCoinType: VSUI, assetId: 5 },
        { userClaimableReward: 12.4, rewardCoinType: NAVX, assetId: 7 },
        { userClaimableReward: 8.2, rewardCoinType: NS, assetId: 11 },
      ] as never;
      vi.mocked(getUserAvailableLendingRewards).mockResolvedValue(mockRewards);

      const pending = await getPendingRewards(fakeClient, VALID_ADDRESS);
      const tx = new Transaction();
      tx.setSender(VALID_ADDRESS);
      const claimable = await addClaimRewardsToTx(tx, fakeClient, VALID_ADDRESS);

      expect(totalsByCoinType(pending)).toEqual(totalsByCoinType(claimable));
      expect(structuralView(pending)).toEqual(structuralView(claimable));
      expect(pending).toHaveLength(3);
      expect(claimable).toHaveLength(3);
    });

    it('both throw PROTOCOL_UNAVAILABLE when NAVI is degraded — no silent disagreement', async () => {
      vi.mocked(getUserAvailableLendingRewards).mockRejectedValue(new Error('NAVI 503'));

      await expect(getPendingRewards(fakeClient, VALID_ADDRESS)).rejects.toMatchObject({
        code: 'PROTOCOL_UNAVAILABLE',
      });
      const tx = new Transaction();
      tx.setSender(VALID_ADDRESS);
      await expect(addClaimRewardsToTx(tx, fakeClient, VALID_ADDRESS)).rejects.toMatchObject({
        code: 'PROTOCOL_UNAVAILABLE',
      });
    });
  });
});
