// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;

/**
 * @title FitnessEvaluator
 * @notice In-process (revm) batch fitness evaluator for Push3 evolution.
 *
 * Replaces the Anvil+forge-script pipeline with in-process EVM execution.
 * Uses Foundry's native revm backend: vm.snapshot/revertTo are memory operations
 * with no JSON-RPC overhead, giving 100-1000x speedup over per-candidate Anvil.
 *
 * Architecture:
 *   batch-eval.sh compiles each candidate (Push3→Solidity→bytecode) and writes a
 *   two-file manifest (ids.txt + bytecodes.txt).  This test reads the manifest,
 *   forks Base mainnet once, deploys the full KRAIKEN stack once, then for each
 *   candidate:
 *     1. snapshot → etch candidate bytecode → UUPS upgrade proxy → bootstrap
 *     2. For each attack: snapshot → execute → accumulate lm_eth_total → revert
 *     3. Emit JSON score line
 *     4. Revert to pre-bootstrap snapshot
 *
 * Required env vars:
 *   BASE_RPC_URL          Base network RPC endpoint (for fork)
 *   FITNESS_MANIFEST_DIR  Directory containing ids.txt and bytecodes.txt
 *
 * Optional env vars:
 *   ATTACKS_DIR  Path to *.jsonl attack files (default: script/backtesting/attacks)
 *
 * Run:
 *   BASE_RPC_URL=https://mainnet.base.org \
 *   FITNESS_MANIFEST_DIR=/tmp/manifest \
 *   forge test --match-contract FitnessEvaluator --match-test testBatchEvaluate -vv
 */

import "forge-std/Test.sol";
import { Kraiken } from "../src/Kraiken.sol";
import { Stake } from "../src/Stake.sol";
import { Optimizer } from "../src/Optimizer.sol";
import { OptimizerInput } from "../src/IOptimizer.sol";
import { LiquidityManager } from "../src/LiquidityManager.sol";
import { ERC1967Proxy } from "@openzeppelin/proxy/ERC1967/ERC1967Proxy.sol";
import { UUPSUpgradeable } from "@openzeppelin/proxy/utils/UUPSUpgradeable.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
import { IUniswapV3Factory } from "@uniswap-v3-core/interfaces/IUniswapV3Factory.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.sol";
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
import { UniswapHelpers } from "../src/helpers/UniswapHelpers.sol";
import { IWETH9 } from "../src/interfaces/IWETH9.sol";

// ─── External interfaces (mirrors AttackRunner.s.sol) ─────────────────────────

interface ISwapRouter02 {
    struct ExactInputSingleParams {
        address tokenIn;
        address tokenOut;
        uint24 fee;
        address recipient;
        uint256 amountIn;
        uint256 amountOutMinimum;
        uint160 sqrtPriceLimitX96;
    }

    function exactInputSingle(ExactInputSingleParams calldata params) external returns (uint256);
}

interface ILM {
    function getVWAP() external view returns (uint256);
    function positions(uint8 stage) external view returns (uint128 liquidity, int24 tickLower, int24 tickUpper);
    function recenter() external returns (bool);
}

interface IStake {
    function snatch(uint256 assets, address receiver, uint32 taxRate, uint256[] calldata positionsToSnatch)
        external
        returns (uint256 positionId);
    function exitPosition(uint256 positionId) external;
}

interface INonfungiblePositionManager {
    struct MintParams {
        address token0;
        address token1;
        uint24 fee;
        int24 tickLower;
        int24 tickUpper;
        uint256 amount0Desired;
        uint256 amount1Desired;
        uint256 amount0Min;
        uint256 amount1Min;
        address recipient;
        uint256 deadline;
    }

    struct DecreaseLiquidityParams {
        uint256 tokenId;
        uint128 liquidity;
        uint256 amount0Min;
        uint256 amount1Min;
        uint256 deadline;
    }

    struct CollectParams {
        uint256 tokenId;
        address recipient;
        uint128 amount0Max;
        uint128 amount1Max;
    }

    function mint(MintParams calldata params) external payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1);
    function positions(uint256 tokenId)
        external
        view
        returns (
            uint96 nonce,
            address operator,
            address token0,
            address token1,
            uint24 fee,
            int24 tickLower,
            int24 tickUpper,
            uint128 liquidity,
            uint256 feeGrowthInside0LastX128,
            uint256 feeGrowthInside1LastX128,
            uint128 tokensOwed0,
            uint128 tokensOwed1
        );
    function decreaseLiquidity(DecreaseLiquidityParams calldata params) external payable returns (uint256 amount0, uint256 amount1);
    function collect(CollectParams calldata params) external payable returns (uint256 amount0, uint256 amount1);
}

// ─── Main test contract ────────────────────────────────────────────────────────

contract FitnessEvaluator is Test {
    using UniswapHelpers for IUniswapV3Pool;

    // ─── Base network constants ───────────────────────────────────────────────

    uint24 internal constant POOL_FEE = 10_000;
    // Base mainnet WETH — https://basescan.org/address/0x4200000000000000000000000000000000000006
    address internal constant WETH_ADDR = 0x4200000000000000000000000000000000000006;
    // Base mainnet SwapRouter02 — https://basescan.org/address/0x2626664c2603336E57B271c5C0b26F421741e481
    address internal constant SWAP_ROUTER = 0x2626664c2603336E57B271c5C0b26F421741e481;
    // Base mainnet NonfungiblePositionManager — https://basescan.org/address/0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1
    address internal constant NPM_ADDR = 0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1;
    // Base mainnet Uniswap V3 Factory — https://basescan.org/address/0x33128a8fC17869897dcE68Ed026d694621f6FDfD
    address internal constant V3_FACTORY = 0x33128a8fC17869897dcE68Ed026d694621f6FDfD;
    /// @dev Intentionally differs from DeployBaseMainnet.sol (0xf6a3...D9011).
    ///      On a Base mainnet fork that address already has contract bytecode, which
    ///      causes LiquidityManager.setFeeDestination() to set feeDestinationLocked=true
    ///      and subsequently revert if called again during snapshot/revert cycles.
    ///      This keccak-derived address is a guaranteed EOA on any live network.
    address internal constant FEE_DEST = 0x8A9145E1Ea4C4d7FB08cF1011c8ac1F0e10F9383;

    /// @dev Fixed address used with vm.etch to inject candidate bytecode.
    ///      Chosen to be deterministic and not collide with real Base addresses.
    address internal constant IMPL_SLOT = address(uint160(uint256(keccak256("fitness.impl.slot"))));

    /// @dev Must match Optimizer.CALCULATE_PARAMS_GAS_LIMIT. Candidates that exceed
    ///      this limit would unconditionally produce bear defaults in production and
    ///      are disqualified (fitness = 0) rather than scored against their theoretical output.
    uint256 internal constant CALCULATE_PARAMS_GAS_LIMIT = 500_000;

    /// @dev Soft gas penalty: wei deducted from fitness per gas unit used by calculateParams.
    ///      Creates selection pressure toward leaner programs while keeping gas as a
    ///      secondary criterion (ETH retention still dominates).
    ///      At 15 k gas (current seed): ~1.5e17 wei penalty.
    ///      At 500 k gas (hard cap boundary): ~5e18 wei penalty.
    uint256 internal constant GAS_PENALTY_FACTOR = 1e13;

    // ─── Anvil test accounts (deterministic mnemonic) ────────────────────────

    /// @dev Account 8 — adversary (10 000 ETH in Anvil; funded via vm.deal here)
    uint256 internal constant ADV_PK = 0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97;
    /// @dev Account 2 — recenter caller (recenter() is now permissionless)
    uint256 internal constant RECENTER_PK = 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a;

    // ─── Runtime state ────────────────────────────────────────────────────────

    address internal lmAddr;
    address internal krkAddr;
    address internal stakeAddr;
    address internal optProxy;
    address internal advAddr;
    address internal recenterAddr;
    IUniswapV3Pool internal pool;
    bool internal token0isWeth;

    /// @dev Mirrors AttackRunner._stakedPositionIds: position IDs returned by stake ops.
    ///      vm.snapshot/revertTo reverts this array's storage between attacks.
    uint256[] internal _stakedPositionIds;

    /// @dev NPM tokenIds returned by mint_lp ops (in insertion order).
    ///      burn_lp references positions by 1-based index into this array so that
    ///      attack files are fork-block-independent (tokenIds vary by fork tip).
    uint256[] internal _mintedNpmTokenIds;

    // ─── Entry point ─────────────────────────────────────────────────────────

    /**
     * @notice Batch fitness evaluator: score all candidates in the manifest.
     *
     * Reads FITNESS_MANIFEST_DIR/{ids.txt,bytecodes.txt} line-by-line.
     * Outputs one JSON line per candidate to stdout:
     *   {"candidate_id":"gen0_c000","fitness":1234567890}
     *
     * Skipped (with a pass) if BASE_RPC_URL is not set, so CI without a Base
     * RPC key does not fail the test suite.
     */
    function testBatchEvaluate() public {
        string memory rpcUrl = vm.envOr("BASE_RPC_URL", string(""));
        vm.skip(bytes(rpcUrl).length == 0);

        string memory manifestDir = vm.envOr("FITNESS_MANIFEST_DIR", string(""));
        require(bytes(manifestDir).length > 0, "FITNESS_MANIFEST_DIR env var required");

        string memory attacksDir = vm.envOr("ATTACKS_DIR", string("script/backtesting/attacks"));

        // Fork Base mainnet so Uniswap V3, WETH, etc. exist at canonical addresses.
        vm.createSelectFork(rpcUrl);

        advAddr = vm.addr(ADV_PK);
        recenterAddr = vm.addr(RECENTER_PK);

        // Deploy the full KRAIKEN stack once on the fork.
        _deploy();

        // Snapshot after deployment (pre-bootstrap, pre-candidate-specific state).
        uint256 baseSnap = vm.snapshot();

        // Discover attack files (sorted alphabetically by path).
        string memory idsFile = string.concat(manifestDir, "/ids.txt");
        string memory bytecodesFile = string.concat(manifestDir, "/bytecodes.txt");

        // Process candidates one at a time.
        while (true) {
            string memory candidateId = vm.readLine(idsFile);
            string memory bytecodeHex = vm.readLine(bytecodesFile);
            if (bytes(candidateId).length == 0) break;
            if (bytes(bytecodeHex).length == 0) {
                console.log("FitnessEvaluator: bytecodes.txt EOF before ids.txt - pipeline mismatch, stopping");
                break;
            }

            // Revert to clean post-deploy state for each candidate.
            vm.revertTo(baseSnap);
            baseSnap = vm.snapshot();

            // Etch candidate optimizer bytecode onto the implementation address
            // and update the ERC1967 implementation slot directly.
            // Skips UUPS upgradeTo() check — candidates are standalone contracts
            // (OptimizerV3Push3) without UUPSUpgradeable inheritance.
            // This is safe because we only care about calculateParams() output.
            bytes memory candidateBytecode = vm.parseBytes(bytecodeHex);
            if (candidateBytecode.length == 0) {
                console.log(string.concat('{"candidate_id":"', candidateId, '","fitness":0,"error":"empty_bytecode"}'));
                continue;
            }
            vm.etch(IMPL_SLOT, candidateBytecode);
            // ERC1967 implementation slot = keccak256("eip1967.proxy.implementation") - 1
            bytes32 ERC1967_IMPL = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
            vm.store(optProxy, ERC1967_IMPL, bytes32(uint256(uint160(IMPL_SLOT))));

            // Bootstrap: fund LM, initial recenter.
            if (!_bootstrap()) {
                console.log(string.concat('{"candidate_id":"', candidateId, '","fitness":0,"error":"bootstrap_failed"}'));
                continue;
            }

            // Measure gas used by calculateParams with fixed representative inputs.
            // Fixed inputs ensure fair, reproducible comparison across all candidates.
            // Uses slot 0 = 50% staked, slot 1 = 5% avg tax rate; remaining slots = 0.
            OptimizerInput[8] memory sampleInputs;
            sampleInputs[0] = OptimizerInput({ mantissa: 5e17, shift: 0 });
            sampleInputs[1] = OptimizerInput({ mantissa: 5e16, shift: 0 });
            uint256 gasBefore = gasleft();
            try Optimizer(optProxy).calculateParams(sampleInputs) returns (uint256, uint256, uint24, uint256) { } catch { }
            uint256 gasForCalcParams = gasBefore - gasleft();

            // Hard disqualification: candidates that exceed the production gas cap would
            // unconditionally produce bear defaults from getLiquidityParams() in every
            // recenter call — equivalent to deploying the bear-defaults optimizer.
            // Score them as 0 so the evolution pipeline never selects a program that is
            // functionally dead on-chain.
            if (gasForCalcParams > CALCULATE_PARAMS_GAS_LIMIT) {
                console.log(
                    string.concat(
                        '{"candidate_id":"', candidateId,
                        '","fitness":0,"error":"gas_over_limit","gas_used":', _uint2str(gasForCalcParams),
                        "}"
                    )
                );
                continue;
            }

            // Score: sum lm_eth_total across all attack sequences.
            uint256 totalFitness = 0;
            Vm.DirEntry[] memory entries = vm.readDir(attacksDir);
            for (uint256 i = 0; i < entries.length; i++) {
                if (entries[i].isDir || !_endsWith(entries[i].path, ".jsonl")) continue;

                uint256 atkSnap = vm.snapshot();
                uint256 score = _runAttack(entries[i].path);
                totalFitness += score;
                vm.revertTo(atkSnap);
            }

            // Apply soft gas penalty: fitness = score - (gasUsed * GAS_PENALTY_FACTOR).
            // Leaner programs win ties; programs at the hard-cap boundary incur ~2 ETH penalty.
            uint256 gasPenalty = gasForCalcParams * GAS_PENALTY_FACTOR;
            uint256 adjustedFitness = totalFitness > gasPenalty ? totalFitness - gasPenalty : 0;

            // Emit score as a JSON line (parsed by batch-eval.sh).
            console.log(
                string.concat(
                    '{"candidate_id":"', candidateId,
                    '","fitness":', _uint2str(adjustedFitness),
                    ',"gas_used":', _uint2str(gasForCalcParams),
                    "}"
                )
            );
        }

        // Close manifest files.
        vm.closeFile(idsFile);
        vm.closeFile(bytecodesFile);
    }

    // ─── Deployment ───────────────────────────────────────────────────────────

    /**
     * @notice Deploy the full KRAIKEN stack (mirrors DeployLocal.sol).
     * @dev All contracts are deployed as address(this) (the test contract),
     *      which becomes the UUPS admin for the Optimizer proxy.
     */
    function _deploy() internal {
        // Deploy Kraiken token.
        Kraiken kraiken = new Kraiken("Kraiken", "KRK");
        krkAddr = address(kraiken);
        token0isWeth = WETH_ADDR < krkAddr;

        // Deploy Stake.
        Stake stake = new Stake(krkAddr, FEE_DEST);
        stakeAddr = address(stake);
        kraiken.setStakingPool(stakeAddr);

        // Get or create Uniswap V3 pool.
        IUniswapV3Factory factory = IUniswapV3Factory(V3_FACTORY);
        address poolAddr = factory.getPool(WETH_ADDR, krkAddr, POOL_FEE);
        if (poolAddr == address(0)) {
            poolAddr = factory.createPool(WETH_ADDR, krkAddr, POOL_FEE);
        }
        pool = IUniswapV3Pool(poolAddr);

        // Initialize pool at 1-cent price if not already initialized.
        (uint160 sqrtPriceX96,,,,,,) = pool.slot0();
        if (sqrtPriceX96 == 0) {
            pool.initializePoolFor1Cent(token0isWeth);
        }

        // Deploy Optimizer implementation + UUPS proxy.
        // address(this) (test contract) becomes the UUPS admin via initialize.
        Optimizer optimizerImpl = new Optimizer();
        bytes memory initData = abi.encodeWithSignature("initialize(address,address)", krkAddr, stakeAddr);
        ERC1967Proxy proxy = new ERC1967Proxy(address(optimizerImpl), initData);
        optProxy = address(proxy);

        // Deploy LiquidityManager.
        LiquidityManager lm = new LiquidityManager(V3_FACTORY, WETH_ADDR, krkAddr, optProxy);
        lmAddr = address(lm);

        // Wire contracts together.
        lm.setFeeDestination(FEE_DEST);
        kraiken.setLiquidityManager(lmAddr);
    }

    // ─── Bootstrap ────────────────────────────────────────────────────────────

    /**
     * @notice Bootstrap LM state for a candidate evaluation (mirrors fitness.sh bootstrap).
     *
     * Steps (same order as fitness.sh):
     *   a. Fund adversary account and wrap ETH → WETH.
     *   b. Transfer 1000 WETH to LM.
     *   c. Wrap 9000 WETH for adversary trades + set approvals.
     *   d. Initial recenter (callable by anyone: cooldown passes because block.timestamp on a
     *      Base fork is a large value >> 60; TWAP passes because the pool has existing history).
     */
    function _bootstrap() internal returns (bool) {
        // a. Fund adversary with ETH.
        vm.deal(advAddr, 10_000 ether);

        // c. Wrap 1000 ETH → WETH and send to LM.
        vm.startPrank(advAddr);
        IWETH9(WETH_ADDR).deposit{ value: 1_000 ether }();
        IERC20(WETH_ADDR).transfer(lmAddr, 1_000 ether);
        vm.stopPrank();

        // d. Wrap remaining 9000 ETH for trade operations + set approvals.
        vm.startPrank(advAddr);
        IWETH9(WETH_ADDR).deposit{ value: 9_000 ether }();
        IERC20(WETH_ADDR).approve(SWAP_ROUTER, type(uint256).max);
        IERC20(WETH_ADDR).approve(NPM_ADDR, type(uint256).max);
        IERC20(krkAddr).approve(SWAP_ROUTER, type(uint256).max);
        IERC20(krkAddr).approve(stakeAddr, type(uint256).max);
        IERC20(krkAddr).approve(NPM_ADDR, type(uint256).max);
        vm.stopPrank();

        // d. Initial recenter: no ANCHOR position exists yet so amplitude check is skipped.
        //    Cooldown passes (Base fork timestamp >> 60). TWAP passes (existing pool history).
        //    If all retries fail, revert with a clear message — silent failure would make every
        //    candidate score identically (all lm_eth_total = free WETH only, no positions).
        bool recentered = false;
        for (uint256 _attempt = 0; _attempt < 5; _attempt++) {
            if (_attempt > 0) {
                vm.roll(block.number + 50);
                vm.warp(block.timestamp + 600);
            }
            vm.prank(recenterAddr);
            try ILM(lmAddr).recenter() returns (bool) {
                recentered = true;
                break;
            } catch (bytes memory reason) {
                console.log(string.concat("recenter attempt ", vm.toString(_attempt), " failed"));
                console.logBytes(reason);
            }
        }
        if (!recentered) {
            return false;
        }
        return true;
    }

    // ─── Attack execution ─────────────────────────────────────────────────────

    /**
     * @notice Execute one attack sequence and return the final lm_eth_total.
     * @param attackFile Path to the *.jsonl attack file.
     */
    function _runAttack(string memory attackFile) internal returns (uint256) {
        // Reset file read position so each call to _runAttack starts from line 1.
        vm.closeFile(attackFile);
        // vm.revertTo() reverts all EVM state including test contract storage, so these
        // arrays are already empty after revert.  Explicit delete is a defensive reset
        // for the first attack (no preceding revert) and any future call-path changes.
        delete _stakedPositionIds;
        delete _mintedNpmTokenIds;

        string memory line = vm.readLine(attackFile);
        while (bytes(line).length > 0) {
            // Skip comment lines (e.g. "// schema-version: 1" header).
            if (bytes(line).length >= 2 && bytes(line)[0] == 0x2F && bytes(line)[1] == 0x2F) {
                line = vm.readLine(attackFile);
                continue;
            }
            _executeOp(line);
            line = vm.readLine(attackFile);
        }

        return _computeLmEthTotal();
    }

    /**
     * @notice Execute a single attack operation (mirrors AttackRunner._execute).
     */
    function _executeOp(string memory line) internal {
        string memory op = vm.parseJsonString(line, ".op");

        if (_eq(op, "buy")) {
            uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount"));
            vm.prank(advAddr);
            try ISwapRouter02(SWAP_ROUTER).exactInputSingle(
                ISwapRouter02.ExactInputSingleParams({
                    tokenIn: WETH_ADDR,
                    tokenOut: krkAddr,
                    fee: POOL_FEE,
                    recipient: advAddr,
                    amountIn: amount,
                    amountOutMinimum: 0,
                    sqrtPriceLimitX96: 0
                })
            ) { } catch { }
        } else if (_eq(op, "sell")) {
            string memory amtStr = vm.parseJsonString(line, ".amount");
            uint256 amount = _eq(amtStr, "all") ? IERC20(krkAddr).balanceOf(advAddr) : vm.parseUint(amtStr);
            if (amount == 0) return;
            vm.prank(advAddr);
            try ISwapRouter02(SWAP_ROUTER).exactInputSingle(
                ISwapRouter02.ExactInputSingleParams({
                    tokenIn: krkAddr,
                    tokenOut: WETH_ADDR,
                    fee: POOL_FEE,
                    recipient: advAddr,
                    amountIn: amount,
                    amountOutMinimum: 0,
                    sqrtPriceLimitX96: 0
                })
            ) { } catch { }
        } else if (_eq(op, "recenter")) {
            vm.prank(recenterAddr);
            try ILM(lmAddr).recenter() { } catch { }
        } else if (_eq(op, "stake")) {
            uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount"));
            uint32 taxRate = uint32(vm.parseJsonUint(line, ".taxRateIndex"));
            vm.prank(advAddr);
            try IStake(stakeAddr).snatch(amount, advAddr, taxRate, new uint256[](0)) returns (uint256 posId) {
                _stakedPositionIds.push(posId);
            } catch { }
        } else if (_eq(op, "unstake")) {
            uint256 posIndex = vm.parseJsonUint(line, ".positionId");
            if (posIndex < 1 || posIndex > _stakedPositionIds.length) return;
            vm.prank(advAddr);
            try IStake(stakeAddr).exitPosition(_stakedPositionIds[posIndex - 1]) { } catch { }
        } else if (_eq(op, "mine")) {
            uint256 blocks = vm.parseJsonUint(line, ".blocks");
            vm.roll(block.number + blocks);
        } else if (_eq(op, "mint_lp")) {
            int24 tickLower = int24(vm.parseJsonInt(line, ".tickLower"));
            int24 tickUpper = int24(vm.parseJsonInt(line, ".tickUpper"));
            uint256 amount0 = vm.parseUint(vm.parseJsonString(line, ".amount0"));
            uint256 amount1 = vm.parseUint(vm.parseJsonString(line, ".amount1"));
            (address t0, address t1) = token0isWeth ? (WETH_ADDR, krkAddr) : (krkAddr, WETH_ADDR);
            vm.prank(advAddr);
            // Track the returned tokenId so burn_lp can reference it by 1-based index,
            // making attack files fork-block-independent (NPM tokenIds depend on fork tip).
            try INonfungiblePositionManager(NPM_ADDR).mint(
                INonfungiblePositionManager.MintParams({
                    token0: t0,
                    token1: t1,
                    fee: POOL_FEE,
                    tickLower: tickLower,
                    tickUpper: tickUpper,
                    amount0Desired: amount0,
                    amount1Desired: amount1,
                    amount0Min: 0,
                    amount1Min: 0,
                    recipient: advAddr,
                    deadline: block.timestamp + 3600
                })
            ) returns (uint256 mintedTokenId, uint128, uint256, uint256) {
                _mintedNpmTokenIds.push(mintedTokenId);
            } catch { }
        } else if (_eq(op, "burn_lp")) {
            // .tokenId in the attack file is a 1-based index into _mintedNpmTokenIds
            // (positions created by mint_lp ops in this run), not a raw NPM tokenId.
            // This mirrors the stake/unstake index pattern and avoids fork-block sensitivity.
            uint256 tokenIndex = vm.parseJsonUint(line, ".tokenId");
            if (tokenIndex < 1 || tokenIndex > _mintedNpmTokenIds.length) return;
            uint256 tokenId = _mintedNpmTokenIds[tokenIndex - 1];
            (,,,,,, , uint128 liquidity,,,,) = INonfungiblePositionManager(NPM_ADDR).positions(tokenId);
            if (liquidity == 0) return;
            vm.startPrank(advAddr);
            try INonfungiblePositionManager(NPM_ADDR).decreaseLiquidity(
                INonfungiblePositionManager.DecreaseLiquidityParams({
                    tokenId: tokenId,
                    liquidity: liquidity,
                    amount0Min: 0,
                    amount1Min: 0,
                    deadline: block.timestamp + 3600
                })
            ) {
                try INonfungiblePositionManager(NPM_ADDR).collect(
                    INonfungiblePositionManager.CollectParams({
                        tokenId: tokenId,
                        recipient: advAddr,
                        amount0Max: type(uint128).max,
                        amount1Max: type(uint128).max
                    })
                ) { } catch { }
            } catch { }
            vm.stopPrank();
        }
        // Unknown ops are silently ignored (mirrors AttackRunner behaviour).
    }

    // ─── Score computation ────────────────────────────────────────────────────

    /**
     * @notice Compute lm_eth_total = free ETH + free WETH + sum(WETH in each position).
     *         Only counts real ETH reserves — KRK token value is excluded to prevent
     *         evolution from gaming the metric by inflating token price through positioning.
     */
    function _computeLmEthTotal() internal view returns (uint256) {
        (uint160 sqrtPriceX96,,,,,,) = pool.slot0();

        uint256 lmEthFree = lmAddr.balance;
        uint256 lmWethFree = IERC20(WETH_ADDR).balanceOf(lmAddr);

        (uint128 fLiq, int24 fLo, int24 fHi) = ILM(lmAddr).positions(0); // FLOOR
        (uint128 aLiq, int24 aLo, int24 aHi) = ILM(lmAddr).positions(1); // ANCHOR
        (uint128 dLiq, int24 dLo, int24 dHi) = ILM(lmAddr).positions(2); // DISCOVERY

        return lmEthFree
            + lmWethFree
            + _positionEthOnly(sqrtPriceX96, fLo, fHi, fLiq)
            + _positionEthOnly(sqrtPriceX96, aLo, aHi, aLiq)
            + _positionEthOnly(sqrtPriceX96, dLo, dHi, dLiq);
    }

    /**
     * @notice WETH-only component of a Uniswap V3 position at the current price.
     *         Ignores KRK token value entirely — counts only the actual ETH backing.
     */
    function _positionEthOnly(
        uint160 sqrtPriceX96,
        int24 tickLower,
        int24 tickUpper,
        uint128 liquidity
    )
        internal
        view
        returns (uint256)
    {
        if (liquidity == 0) return 0;
        uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(tickLower);
        uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(tickUpper);
        (uint256 amount0, uint256 amount1) =
            LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtRatioAX96, sqrtRatioBX96, liquidity);

        return token0isWeth ? amount0 : amount1;
    }

    // ─── Utilities ────────────────────────────────────────────────────────────

    function _eq(string memory a, string memory b) internal pure returns (bool) {
        return keccak256(bytes(a)) == keccak256(bytes(b));
    }

    function _endsWith(string memory str, string memory suffix) internal pure returns (bool) {
        bytes memory bStr = bytes(str);
        bytes memory bSuf = bytes(suffix);
        if (bStr.length < bSuf.length) return false;
        uint256 offset = bStr.length - bSuf.length;
        for (uint256 i = 0; i < bSuf.length; i++) {
            if (bStr[offset + i] != bSuf[i]) return false;
        }
        return true;
    }

    function _uint2str(uint256 n) internal pure returns (string memory) {
        if (n == 0) return "0";
        uint256 temp = n;
        uint256 digits;
        while (temp != 0) {
            digits++;
            temp /= 10;
        }
        bytes memory buffer = new bytes(digits);
        while (n != 0) {
            digits--;
            buffer[digits] = bytes1(uint8(48 + (n % 10)));
            n /= 10;
        }
        return string(buffer);
    }
}
