pragma solidity 0.8.20;

import {Test} from "forge-std/Test.sol";

import "../../src/FatsoERC20.sol";
import "../../src/StinkyERC20.sol";
import "../../src/GhstERC20.sol";
import "../../src/GhostAuthority.sol";
import "../../src/StakingDistributor.sol";
import "../../src/Treasury.sol";
import "../../src/Staking.sol";
import "../../src/mocks/ERC20Mock.sol";
import "../../src/Gatekeeper.sol";

contract StakingTest is Test {
    address constant initializer       = 0x0000000000000000000000000000000000000001;
    address constant governor          = 0x0000000000000000000000000000000000000003;
    address constant guardian          = 0x0000000000000000000000000000000000000004;
    address constant policy            = 0x0000000000000000000000000000000000000005;
    address constant vault             = 0x0000000000000000000000000000000000000006;
    address constant alice             = 0x0000000000000000000000000000000000000007;
    address constant bob               = 0x0000000000000000000000000000000000000008;

    uint48 public constant EPOCH_LENGTH = 2200;
    uint48 public constant EPOCH_NUMBER = 1;
    uint48 public constant EPOCH_END_TIME = 1337;

    uint256 constant public INITIAL_INDEX = 10819917194513808e56;
    uint256 constant public TOTAL_INITIAL_SUPPLY = 5000000000000000;

    Fatso ftso;
    Stinky stnk;
    Ghost ghst;
    GhostStaking staking;
    GhostTreasury treasury;
    GhostAuthority authority;
    Gatekeeper gatekeeper;

    uint256 public constant amount = 69;
    uint256 public constant bigAmount = amount * 1e9;

    event DistributorSet(address distributor);
    event WarmupSet(uint256 warmup);
    event Ghosted(bytes32 indexed receiver, uint256 indexed amount);

    function setUp() public {
        vm.startPrank(initializer);
        authority = new GhostAuthority(
            governor,
            guardian,
            policy,
            vault
        );
        ftso = new Fatso(address(authority), "Fatso", "FTSO");
        stnk = new Stinky(INITIAL_INDEX, "Stinky", "STNK");
        ghst = new Ghost(address(stnk), "Ghost", "GHST");
        staking = new GhostStaking(
            address(ftso),
            address(stnk),
            address(ghst),
            EPOCH_LENGTH,
            EPOCH_NUMBER,
            EPOCH_END_TIME,
            address(authority)
        );
        treasury = new GhostTreasury(address(ftso), 69, address(authority));
        stnk.initialize(address(staking), address(treasury), address(ghst));
        ghst.initialize(address(staking));
        gatekeeper = new Gatekeeper(address(staking), 0);
        vm.stopPrank();
    }

    function test_correctAfterConstruction() public view {
        assertEq(staking.stnk(), address(stnk));
        assertEq(staking.ftso(), address(ftso));
        assertEq(staking.ghst(), address(ghst));
        (uint48 length, uint48 number, uint48 end, uint256 distribute) = staking.epoch();
        assertEq(length, EPOCH_LENGTH);
        assertEq(number, EPOCH_NUMBER);
        assertEq(end, EPOCH_END_TIME);
        assertEq(distribute, 0);
    }

    function test_governorCouldSetDistributor(address maybeDistributor) public {
        vm.assume(maybeDistributor != address(0));
        assertEq(staking.distributor(), address(0));
        vm.prank(governor);
        staking.setDistributor(maybeDistributor);
        assertEq(staking.distributor(), maybeDistributor);
    }

    function test_emitsDistributorSetEvent(address maybeDistributor) public {
        vm.assume(maybeDistributor != address(0));
        vm.expectEmit(true, true, true, false, address(staking));
        emit DistributorSet(maybeDistributor);
        vm.prank(governor);
        staking.setDistributor(maybeDistributor);
    }

    function test_arbitraryAddressCouldNotAddDistributor(address someone, address maybeDistributor) public {
        vm.assume(maybeDistributor != address(0) && someone != governor);
        vm.expectRevert();
        vm.prank(someone);
        staking.setDistributor(maybeDistributor);
    }

    function test_governorCouldSetWarmupPeriod(uint48 maybeWarmupPeriod) public {
        vm.assume(maybeWarmupPeriod > 0);
        assertEq(staking.warmupPeriod(), 0);
        vm.prank(governor);
        staking.setWarmupPeriod(maybeWarmupPeriod);
        assertEq(staking.warmupPeriod(), maybeWarmupPeriod);
    }

    function test_emitsWarmupEvent(uint48 maybeWarmupPeriod) public {
        vm.expectEmit(true, true, true, false, address(staking));
        emit WarmupSet(maybeWarmupPeriod);
        vm.prank(governor);
        staking.setWarmupPeriod(maybeWarmupPeriod);
    }

    function test_arbitraryAddressCouldNotSetWarmupPeriod(address someone, uint48 maybeWarmupPeriod) public {
        vm.assume(maybeWarmupPeriod > 0 && someone != governor);
        vm.expectRevert();
        vm.prank(someone);
        staking.setWarmupPeriod(maybeWarmupPeriod);
    }

    function test_stake_addAmountToTheWarmupWhenClaimIsFalse() public {
        _mintAndApprove(alice, amount);

        vm.prank(alice);
        uint256 rebased = staking.stake(amount, alice, false, false);

        assertEq(ftso.balanceOf(alice), 0);
        assertEq(staking.sharesInWarmup(), stnk.sharesForBalance(rebased));

        (uint256 deposit, uint256 shares, uint48 expiry, bool lock) = staking.warmupInfo(alice);
        assertEq(deposit, amount);
        assertEq(shares, stnk.sharesForBalance(amount));
        assertEq(expiry, 1);
        assertEq(lock, false);
    }

    function test_stake_exchangesFatsoToStinkyWhenClaimIsTrueAndRebasingIsTrue() public {
        _mintAndApprove(alice, amount);

        vm.prank(alice);
        uint256 rebasedAmount = staking.stake(amount, alice, true, true);

        assertEq(ftso.balanceOf(alice), 0);
        assertEq(stnk.balanceOf(alice), rebasedAmount);
    }

    function test_stake_exchangesFatsoForNewlyMintedGhostWhenClaimIsTrueAndRebasingIsFalse() public {
        _mintAndApprove(alice, amount);

        vm.prank(alice);
        uint256 rebasedAmount = staking.stake(amount, alice, false, true);

        assertEq(ftso.balanceOf(alice), 0);
        assertEq(ghst.balanceOf(alice), rebasedAmount);
    }

    function test_stake_addAmountToWarmupWhenClaimIsTrueAndWarmupGtZero() public {
        _mintAndApprove(alice, amount);
        vm.prank(governor);
        staking.setWarmupPeriod(1);

        vm.prank(alice);
        uint256 rebased = staking.stake(amount, alice, false, true);

        assertEq(ftso.balanceOf(alice), 0);
        assertEq(staking.sharesInWarmup(), stnk.sharesForBalance(rebased));

        (uint256 deposit, uint256 shares, uint48 expiry, bool lock) = staking.warmupInfo(alice);
        assertEq(deposit, amount);
        assertEq(shares, stnk.sharesForBalance(amount));
        assertEq(expiry, 2);
        assertEq(lock, false);
    }

    function test_stake_allowsSelfDepositWhenNotLocked() public {
        _mintAndApprove(alice, amount);

        vm.prank(alice);
        staking.stake(amount, bob, false, false);

        assertEq(ftso.balanceOf(alice), 0);
    }

    function test_stake_disablesExternalDepositsWhenLocked() public {
        _mintAndApprove(alice, amount);
        vm.prank(bob);
        staking.toggleLock();

        vm.expectRevert();
        vm.prank(alice);
        staking.stake(amount, bob, false, false);

        assertEq(ftso.balanceOf(alice), amount);
    }

    function test_claim_transferStinkyWhenRebasingIsTrue() public {
        _prepareAndRoll(alice, amount, false, false);

        assertEq(stnk.balanceOf(alice), 0);
        vm.prank(alice);
        uint256 rebased = staking.claim(alice, true);
        assertEq(stnk.balanceOf(alice), rebased);
        assertEq(rebased > 0, true);
    }

    function test_claim_mintsGhostWhenRebasingIsFalse() public {
        _prepareAndRoll(alice, amount, false, false);

        assertEq(ghst.balanceOf(alice), 0);
        vm.prank(alice);
        uint256 rebased = staking.claim(alice, false);
        assertEq(ghst.balanceOf(alice), rebased);
        assertEq(rebased > 0, true);
    }

    function test_claim_preventsExternalClaimsWhenLocked() public {
        _prepareAndRoll(alice, amount, false, false);

        assertEq(ghst.balanceOf(alice), 0);
        vm.prank(alice);
        staking.toggleLock();

        vm.expectRevert();
        vm.prank(bob);
        staking.claim(alice, false);
        assertEq(ghst.balanceOf(alice), 0);
    }

    function test_claim_allowExternalClaimsWhenNotLocked() public {
        _prepareAndRoll(alice, amount, false, false);

        assertEq(ghst.balanceOf(alice), 0);
        vm.prank(bob);
        uint256 rebased = staking.claim(alice, false);
        assertEq(ghst.balanceOf(alice), rebased);
        assertEq(rebased > 0, true);
    }

    function test_claim_doesNothingWhenThereIsNothingToClaim() public {
        assertEq(ghst.balanceOf(alice), 0);
        vm.prank(alice);
        uint256 rebased = staking.claim(alice, false);
        assertEq(ghst.balanceOf(alice), 0);
        assertEq(rebased, 0);
    }

    function test_claim_doesNothingWhenWarmupIsntOver() public {
        _mintAndApprove(alice, amount);
        vm.prank(governor);
        staking.setWarmupPeriod(1337);
        vm.prank(alice);
        staking.stake(amount, alice, false, false);

        assertEq(stnk.balanceOf(alice), 0);
        vm.prank(alice);
        uint256 rebased = staking.claim(alice, true);
        assertEq(stnk.balanceOf(alice), 0);
        assertEq(rebased, 0);
    }

    function test_forefeit_removesStakeFromWarmupAndReturnsFatso() public {
        _prepareAndRoll(alice, amount, false, false);

        assertEq(ftso.balanceOf(alice), 0);
        assertEq(staking.sharesInWarmup(), stnk.sharesForBalance(amount));

        vm.prank(alice);
        uint256 deposit = staking.forfeit();

        assertEq(ftso.balanceOf(alice), deposit);
        assertEq(staking.sharesInWarmup(), 0);

        (uint256 depositInWarmup, uint256 shares, uint48 expiry,) = staking.warmupInfo(alice);
        assertEq(depositInWarmup, 0);
        assertEq(shares, 0);
        assertEq(expiry, 0);
    }

    function test_forefeit_transfersZeroIfThereIsNoBalanceInWarmup() public {
        vm.prank(alice);
        uint256 deposit = staking.forfeit();
        assertEq(deposit, 0);
        assertEq(ftso.balanceOf(alice), 0);
        assertEq(staking.sharesInWarmup(), 0);
    }

    function test_unstake_canRedeemStinkyToFatso() public {
        _prepareAndRoll(alice, amount, true, true);
        uint256 aliceBalance = stnk.balanceOf(alice);
        vm.startPrank(alice);
        stnk.approve(address(staking), aliceBalance);
        staking.unstake(aliceBalance, alice, false, true);
        vm.stopPrank();
        assertEq(stnk.balanceOf(alice), 0);
        assertEq(ftso.balanceOf(alice), amount);
    }

    function test_unstake_canRedeemGhostToFatso() public {
        _prepareAndRoll(alice, amount, false, true);
        uint256 aliceBalance = ghst.balanceOf(alice);
        vm.startPrank(alice);
        ghst.approve(address(staking), aliceBalance);
        vm.roll(block.number + 69);
        staking.unstake(aliceBalance, alice, false, false);
        vm.stopPrank();
        assertEq(ghst.balanceOf(alice), 0);
        assertEq(ftso.balanceOf(alice), ghst.balanceFrom(aliceBalance));
    }

    function test_wrap_convertsStinkyIntoGhost() public {
        _prepareAndRoll(alice, amount, true, true);
        uint256 aliceBalance = stnk.balanceOf(alice);
        assertEq(ghst.balanceOf(alice), 0);

        vm.startPrank(alice);
        stnk.approve(address(staking), aliceBalance);
        uint256 newBalance = staking.wrap(alice, aliceBalance);
        vm.stopPrank();

        assertEq(stnk.balanceOf(alice), 0);
        assertEq(ghst.balanceOf(alice), newBalance);
    }

    function test_wrap_convertsGhostToStinky() public {
        _prepareAndRoll(alice, amount, false, true);
        uint256 aliceBalance = ghst.balanceOf(alice);
        assertEq(stnk.balanceOf(alice), 0);

        vm.roll(block.number + 69);
        vm.startPrank(alice);
        ghst.approve(address(staking), aliceBalance);
        uint256 newBalance = staking.unwrap(alice, aliceBalance);
        vm.stopPrank();

        assertEq(ghst.balanceOf(alice), 0);
        assertEq(stnk.balanceOf(alice), newBalance);
    }

    function test_rebase_doesNothingIfTheBlockIsBeforeTheEpochEndBlock() public {
        (, uint48 numberBefore, uint48 endBefore, uint256 distributeBefore) = staking.epoch();
        assertEq(endBefore > block.timestamp, true);
        staking.rebase();
        (, uint48 numberAfter, uint48 endAfter, uint256 distributeAfter) = staking.epoch();
        assertEq(distributeAfter, distributeBefore);
        assertEq(endAfter, endBefore);
        assertEq(numberAfter, numberBefore);
    }

    function test_rebase_incrementsEpochNumberAndCallRebase() public {
        (uint48 length, uint48 numberBefore, uint48 endBefore,) = staking.epoch();
        assertEq(endBefore > block.timestamp, true);
        vm.warp(endBefore);
        staking.rebase();
        (, uint48 numberAfter, uint48 endAfter,) = staking.epoch();

        assertEq(endAfter, endBefore + length);
        assertEq(numberAfter, numberBefore + 1);
    }

    function test_rebase_whenTheFatsoBalanceEqualsToStinkySupplyZeroDistribution() public {
        (uint48 length, uint48 numberBefore, uint48 endBefore, uint256 distributeBefore) = staking.epoch();
        assertEq(endBefore > block.timestamp, true);

        vm.prank(address(staking));
        stnk.transfer(alice, amount);
        vm.prank(vault);
        ftso.mint(address(staking), amount);

        assertEq(stnk.balanceOf(alice), ftso.balanceOf(address(staking)));

        vm.warp(endBefore);
        staking.rebase();

        (, uint48 numberAfter, uint48 endAfter, uint256 distributeAfter) = staking.epoch();
        assertEq(endAfter, endBefore + length);
        assertEq(numberAfter, numberBefore + 1);
        assertEq(distributeAfter, distributeBefore);
    }

    function test_rebase_distributeDifferenceBetweenStakedAndTotalSupply() public {
        (uint48 length, uint48 numberBefore, uint48 endBefore,) = staking.epoch();
        assertEq(endBefore > block.timestamp, true);

        vm.prank(address(staking));
        stnk.transfer(alice, amount);
        vm.prank(vault);
        ftso.mint(address(staking), amount * 2);

        vm.warp(endBefore);
        staking.rebase();

        (, uint48 numberAfter, uint48 endAfter, uint256 distributeAfter) = staking.epoch();
        assertEq(endAfter, endBefore + length);
        assertEq(numberAfter, numberBefore + 1);
        assertEq(distributeAfter, amount);
    }

    function test_rebase_callDistributorIfSet() public {
        (uint48 length, uint48 numberBefore, uint48 endBefore,) = staking.epoch();
        assertEq(endBefore > block.timestamp, true);
        uint256 extendedAmount = amount * 1e18;

        GhostDistributor distributor = new GhostDistributor(
            address(treasury),
            address(ftso),
            address(staking),
            address(authority),
            1e4 // 1%
        );
        ERC20Mock reserve = new ERC20Mock("Reserve Token", "RET");

        vm.startPrank(governor);
        authority.pushVault(address(treasury));
        staking.setDistributor(address(distributor));
        treasury.enable(ITreasury.STATUS.REWARDMANAGER, address(distributor), address(0));
        treasury.enable(ITreasury.STATUS.RESERVEDEPOSITOR, alice, address(0));
        treasury.enable(ITreasury.STATUS.RESERVEDEPOSITOR, bob, address(0));
        treasury.enable(ITreasury.STATUS.RESERVETOKEN, address(reserve), address(0));
        vm.stopPrank();

        vm.startPrank(bob);
        reserve.mint(bob, extendedAmount);
        reserve.approve(address(treasury), extendedAmount);
        uint256 zeroProfit = treasury.tokenValue(address(reserve), extendedAmount);
        treasury.deposit(address(reserve), extendedAmount, zeroProfit);
        vm.stopPrank();

        vm.startPrank(alice);
        assertEq(ftso.balanceOf(address(staking)), 0);
        reserve.mint(alice, extendedAmount);
        reserve.approve(address(treasury), extendedAmount);
        uint256 send = treasury.deposit(address(reserve), extendedAmount, 0);
        assertEq(ftso.balanceOf(alice), send);
        assertEq(reserve.balanceOf(address(treasury)), extendedAmount * 2);
        ftso.approve(address(staking), send);
        staking.stake(send, alice, true, true);
        assertEq(ftso.balanceOf(address(staking)), send);
        assertEq(stnk.balanceOf(alice), send);
        vm.stopPrank();

        assertEq(treasury.excessReserves(), send);
        assertEq(treasury.totalReserves(), send * 2);

        skip(endBefore);
        uint256 extraToStaking = send * 1e4 / 1e6;
        vm.prank(address(staking));
        staking.rebase();
        assertEq(ftso.balanceOf(address(staking)), send + extraToStaking);

        (, uint48 numberAfter, uint48 endAfter, uint256 distributeAfter) = staking.epoch();
        assertEq(endAfter, endBefore + length);
        assertEq(numberAfter, numberBefore + 1);
        assertEq(distributeAfter, extraToStaking);
    }

    function test_correctStakeAmountDuringRebase() public {
        uint256 extendedAmount = amount * 1e18;
        uint256 bounty = 1;
        GhostDistributor distributor = new GhostDistributor(
            address(treasury),
            address(ftso),
            address(staking),
            address(authority),
            1e6
        );
        ERC20Mock reserve = new ERC20Mock("Reserve Token", "RET");

        vm.startPrank(governor);
        authority.pushVault(address(treasury));
        staking.setDistributor(address(distributor));
        distributor.setBounty(bounty);
        treasury.enable(ITreasury.STATUS.REWARDMANAGER, address(distributor), address(0));
        treasury.enable(ITreasury.STATUS.RESERVEDEPOSITOR, alice, address(0));
        treasury.enable(ITreasury.STATUS.RESERVETOKEN, address(reserve), address(0));
        vm.stopPrank();

        vm.startPrank(alice);
        reserve.mint(alice, extendedAmount);
        reserve.approve(address(treasury), type(uint256).max);

        uint256 profit = treasury.tokenValue(address(reserve), extendedAmount) - amount;
        treasury.deposit(address(reserve), extendedAmount, profit);
        vm.stopPrank();

        (,, uint48 end,) = staking.epoch();
        skip(end);

        vm.startPrank(alice);
        ftso.approve(address(staking), type(uint256).max);
        staking.stake(amount, alice, true, true);
        vm.stopPrank();

        uint256 postBounty = amount + bounty;

        assertEq(stnk.balanceOf(alice), postBounty);
        assertEq(ftso.balanceOf(address(staking)), postBounty);

        skip(end + EPOCH_LENGTH);
        staking.rebase();

        (,,, uint256 distribute) = staking.epoch();
        assertEq(distribute, postBounty);
        assertEq(stnk.balanceOf(alice), postBounty);
        assertEq(ftso.balanceOf(address(staking)), 2 * postBounty + bounty);
    }

    function test_arbitraryAddressCouldNotAddGatekeeper(address someone, address maybeGatekeeper) public {
        vm.assume(maybeGatekeeper != address(0) && someone != governor);
        vm.expectRevert();
        vm.prank(someone);
        staking.setDistributor(maybeGatekeeper);
    }

    function test_governorCouldSetGatekeeper(address maybeGatekeeper) public {
        vm.assume(maybeGatekeeper != address(0));
        assertEq(staking.gatekeeper(), address(0));
        vm.prank(governor);
        staking.setGatekeeperAddress(maybeGatekeeper);
        assertEq(staking.gatekeeper(), maybeGatekeeper);
    }

    function test_couldNotGhostIfNoGatekeeper() public {
        assertEq(staking.ghostedSupply(), 0);
        vm.expectRevert();
        staking.ghost(bytes32(abi.encodePacked(alice)), amount);
        assertEq(staking.ghostedSupply(), 0);
    }

    function test_couldNotMaterializeIfNoGatekeeper() public {
        assertEq(staking.ghostedSupply(), 0);
        vm.expectRevert();
        staking.materialize(alice, amount, 0, 0); // dummy rx and s
        assertEq(staking.ghostedSupply(), 0);
    }

    function test_couldNotGhostTokensIfNoGhst() public {
        assertEq(staking.gatekeeper(), address(0));
        vm.prank(governor);
        staking.setGatekeeperAddress(address(gatekeeper));
        assertEq(staking.gatekeeper(), address(gatekeeper));

        assertEq(staking.ghostedSupply(), 0);
        vm.expectRevert();
        vm.prank(alice);
        staking.ghost(bytes32(abi.encodePacked(alice)), amount);
        assertEq(staking.ghostedSupply(), 0);
    }

    function test_correctlyGhostTokens() public {
        assertEq(staking.gatekeeper(), address(0));
        vm.prank(governor);
        staking.setGatekeeperAddress(address(gatekeeper));
        assertEq(staking.gatekeeper(), address(gatekeeper));

        _prepareAndRoll(alice, bigAmount, true, true);
        uint256 aliceBalance = stnk.balanceOf(alice);

        vm.startPrank(alice);
        stnk.approve(address(staking), aliceBalance);
        uint256 ghstBalance = staking.wrap(alice, aliceBalance);
        vm.stopPrank();

        assertEq(staking.ghostedSupply(), 0);
        assertEq(stnk.circulatingSupply(), bigAmount - 1); // precision fix
        assertEq(ghst.totalSupply(), ghstBalance);

        vm.prank(alice);
        staking.ghost(bytes32(abi.encodePacked(alice)), ghstBalance);

        assertEq(staking.ghostedSupply(), ghstBalance);
        assertEq(stnk.circulatingSupply(), bigAmount - 1); // precision fix
        assertEq(ghst.totalSupply(), 0);
    }

    function test_ghostTokensEmitsEvent() public {
        assertEq(staking.gatekeeper(), address(0));
        vm.prank(governor);
        staking.setGatekeeperAddress(address(gatekeeper));
        assertEq(staking.gatekeeper(), address(gatekeeper));

        _prepareAndRoll(alice, bigAmount, true, true);
        uint256 aliceBalance = stnk.balanceOf(alice);

        vm.startPrank(alice);
        stnk.approve(address(staking), aliceBalance);
        uint256 ghstBalance = staking.wrap(alice, aliceBalance);
        vm.stopPrank();

        bytes32 receiver = bytes32(abi.encodePacked(alice));
        vm.expectEmit(true, true, true, false, address(gatekeeper));
        emit Ghosted(receiver, ghstBalance);

        vm.prank(alice);
        staking.ghost(receiver, ghstBalance);
    }

    function _mintAndApprove(address who, uint256 value) internal {
        vm.prank(vault);
        ftso.mint(who, value);
        vm.prank(who);
        ftso.approve(address(staking), value);
    }

    function _prepareAndRoll(address who, uint256 value, bool rebase, bool claim) internal returns (uint256) {
        _mintAndApprove(who, value);

        (,, uint48 end,) = staking.epoch();
        skip(end);

        vm.prank(who);
        uint256 rebased = staking.stake(value, who, rebase, claim);

        (,, uint48 expiry,) = staking.warmupInfo(alice);
        vm.roll(expiry);
        return rebased;
    }
}