contract StakedQLT is ERC721Enumerable, Ownable {
    using Math for uint256;

    event Stake(
        uint256 indexed tokenId,
        address staker,
        bytes32 stakingPlan,
        uint256 amount
    );
    event Unstake(uint256 indexed tokenId);
    event Redeem(uint256 indexed tokenId, uint256 amount);
    event Harvest(uint256 indexed tokenId, uint256 rewardAmount);
    event HarvestAll(uint256[] tokenIds, uint256 rewardAmount);
    event StakingPlanCreated(bytes32 name);
    event StakingPlanDeactivated(bytes32 name);
    event StakingRewardRateUpdated(bytes32 name, uint256 rewardRateX96);
    event StakingPowerMultiplierUpdated(bytes32 name, uint256 multiplier);

    QLT private immutable QLTContract;

    uint256 public tokenIdCounter;
    uint64 public harvestStartTime;
    uint64 public unstakeLockupPeriod;

    uint256 public totalStakingAmount;
    address public treasuryAddress;
    mapping(uint256 => StakingInfo) public stakingInfos;
    mapping(bytes32 => StakingPlan) public stakingPlans;

    mapping(address => bool) public authorizedOperators;

    constructor(address _QLTContract)
        ERC721("Staked QLT", "sQLT", "https://staked.quantland.finance/")
    {
        addAuthorizedOperator(msg.sender);
        harvestStartTime = type(uint64).max;
        unstakeLockupPeriod = DEFAULT_UNSTAKE_LOCKUP_PERIOD;

        addStakingPlan("gold", 7 days, (100040 * Q96) / 100000, 1); // APY 3,222 %
        addStakingPlan("platinum", 30 days, (100060 * Q96) / 100000, 3); // APY 19,041 %
        addStakingPlan("diamond", 90 days, (100080 * Q96) / 100000, 5); // APY 110,200 %

        QLTContract = QLT(_QLTContract);
    }

    /* Staking Plan Governance Functions */
    function addStakingPlan(
        bytes32 name,
        uint64 lockupPeriod,
        uint256 rewardRateX96,
        uint64 multiplier
    ) public onlyOwner {
        require(stakingPlans[name].createdAt == 0, "already created");
        StakingPlan storage stakingPlan = stakingPlans[name];
        stakingPlan.name = name;
        stakingPlan.rewardRates.push(
            StakingRewardRate({
                rewardRateX96: rewardRateX96,
                startTime: Time.current_hour_timestamp()
            })
        );
        stakingPlan.multipliers.push(
            StakingPowerMultiplier({
                multiplier: multiplier,
                startTime: Time.block_timestamp()
            })
        );
        stakingPlan.lockupPeriod = lockupPeriod;
        stakingPlan.createdAt = Time.block_timestamp();

        emit StakingPlanCreated(name);
    }

    function deactivateStakingPlan(bytes32 name) public onlyOwner {
        _checkStakingPlanActive(name);

        StakingPlan storage stakingPlan = stakingPlans[name];
        stakingPlan.deactivatedAt = Time.block_timestamp();

        emit StakingPlanDeactivated(name);
    }

    function updateStakingRewardRate(bytes32 name, uint256 rewardRateX96)
        public
        onlyOperator
    {
        _checkStakingPlanActive(name);

        StakingPlan storage stakingPlan = stakingPlans[name];
        stakingPlan.rewardRates.push(
            StakingRewardRate({
                rewardRateX96: rewardRateX96,
                startTime: Time.current_hour_timestamp()
            })
        );

        emit StakingRewardRateUpdated(name, rewardRateX96);
    }

    function updateStakingPowerMultiplier(bytes32 name, uint64 multiplier)
        public
        onlyOperator
    {
        _checkStakingPlanActive(name);

        StakingPlan storage stakingPlan = stakingPlans[name];
        stakingPlan.multipliers.push(
            StakingPowerMultiplier({
                multiplier: multiplier,
                startTime: Time.block_timestamp()
            })
        );

        emit StakingPowerMultiplierUpdated(name, multiplier);
    }

    /* Staking-Related Functions */
    function stake(
        address recipient,
        bytes32 stakingPlan,
        uint256 amount
    ) external returns (uint256 tokenId) {
        require(amount > 0, "amount is 0");
        _checkStakingPlanActive(stakingPlan);

        // transfer QLT
        QLTContract.transferFrom(msg.sender, address(this), amount);

        // mint
        tokenIdCounter += 1;
        tokenId = tokenIdCounter;
        _mint(recipient, tokenId);
        _approve(address(this), tokenId);

        // update staking info
        StakingInfo storage stakingInfo = stakingInfos[tokenId];
        stakingInfo.stakingPlan = stakingPlan;
        stakingInfo.stakedAmount = amount;
        stakingInfo.stakeTime = Time.block_timestamp();
        stakingInfo.lastHarvestTime = Time.current_hour_timestamp();

        // update staking plan info
        stakingPlans[stakingPlan].stakingAmount += amount;
        totalStakingAmount += amount;

        emit Stake(tokenId, recipient, stakingPlan, amount);
    }

    function unstake(uint256 tokenId) external returns (uint256 rewardAmount) {
        _checkOwnershipOfStakingToken(tokenId);

        StakingInfo storage stakingInfo = stakingInfos[tokenId];
        uint64 lockupPeriod = stakingPlans[stakingInfo.stakingPlan]
            .lockupPeriod;
        uint64 stakeTime = stakingInfo.stakeTime;
        uint64 unstakeTime = stakingInfo.unstakeTime;

        if (msg.sender == treasuryAddress) {
            lockupPeriod = 0;
        }

        require(unstakeTime == 0, "already unstaked");
        require(
            Time.block_timestamp() >= (stakeTime + lockupPeriod),
            "still in lockup"
        );

        // harvest first
        rewardAmount = harvestInternal(tokenId);

        // update staking info
        uint256 unstakedAmount = stakingInfo.stakedAmount;
        stakingInfo.unstakeTime = Time.block_timestamp();

        // update staking plan info
        stakingPlans[stakingInfo.stakingPlan].stakingAmount -= unstakedAmount;
        totalStakingAmount -= unstakedAmount;

        emit Unstake(tokenId);
    }

    function redeem(uint256 tokenId) external returns (uint256 redeemedAmount) {
        _checkOwnershipOfStakingToken(tokenId);

        StakingInfo storage stakingInfo = stakingInfos[tokenId];
        uint64 unstakeTime = stakingInfo.unstakeTime;
        uint64 redeemTime = stakingInfo.redeemTime;
        uint64 _unstakeLockupPeriod = unstakeLockupPeriod;

        if (msg.sender == treasuryAddress) {
            _unstakeLockupPeriod = 0;
        }

        // check if can unstake
        require(unstakeTime > 0, "not unstaked");
        require(
            Time.block_timestamp() >= (unstakeTime + _unstakeLockupPeriod),
            "still in lockup"
        );
        require(redeemTime == 0, "already redeemed");

        // recycle and burn staking NFT
        address staker = ownerOf(tokenId);
        transferFrom(msg.sender, address(this), tokenId);
        _burn(tokenId);

        // transfer QLT back to staker
        redeemedAmount = stakingInfo.stakedAmount;
        QLTContract.transfer(staker, redeemedAmount);

        // update staking info
        stakingInfo.redeemTime = Time.block_timestamp();

        emit Redeem(tokenId, redeemedAmount);
    }

    function harvest(uint256 tokenId) external returns (uint256 rewardAmount) {
        return harvestInternal(tokenId);
    }

    function harvestAll(uint256[] calldata tokenIds)
        external
        returns (uint256 rewardAmount)
    {
        for (uint256 i = 0; i < tokenIds.length; i++) {
            rewardAmount += harvestInternal(tokenIds[i]);
        }

        emit HarvestAll(tokenIds, rewardAmount);
    }

    function harvestInternal(uint256 tokenId)
        internal
        returns (uint256 rewardAmount)
    {
        require(Time.block_timestamp() >= harvestStartTime, "come back later");
        _checkOwnershipOfStakingToken(tokenId);

        rewardAmount = getRewardsToHarvest(tokenId);

        if (rewardAmount > 0) {
            // mint QLT to recipient
            QLTContract.mint(ownerOf(tokenId), rewardAmount);

            // update staking info
            StakingInfo storage stakingInfo = stakingInfos[tokenId];
            stakingInfo.lastHarvestTime = Time.current_hour_timestamp();
            stakingInfo.accumulatedStakingReward += rewardAmount;

            // update staking plan info
            StakingPlan storage stakingPlan = stakingPlans[
                stakingInfo.stakingPlan
            ];
            stakingPlan.accumulatedStakingReward += rewardAmount;

            emit Harvest(tokenId, rewardAmount);
        }
    }

    /* Staking State View Functions */
    function getRewardsToHarvest(uint256 tokenId)
        public
        view
        returns (uint256 rewardAmount)
    {
        require(tokenId <= tokenIdCounter, "not existent");

        StakingInfo storage stakingInfo = stakingInfos[tokenId];

        if (stakingInfo.unstakeTime > 0) {
            return 0;
        }

        StakingPlan storage stakingPlan = stakingPlans[stakingInfo.stakingPlan];

        // calculate compounded rewards of QLT
        uint256 stakedAmountX96 = stakingInfo.stakedAmount * Q96;
        uint256 compoundedAmountX96 = stakedAmountX96;
        uint64 rewardEndTime = Time.current_hour_timestamp();
        uint64 lastHarvestTime = stakingInfo.lastHarvestTime;

        StakingRewardRate[] storage rewardRates = stakingPlan.rewardRates;
        uint256 i = rewardRates.length;
        while (i > 0) {
            i--;

            uint64 rewardStartTime = rewardRates[i].startTime;
            uint256 rewardRateX96 = rewardRates[i].rewardRateX96;
            uint256 nCompounds;

            if (rewardEndTime < rewardStartTime) {
                continue;
            }

            if (rewardStartTime >= lastHarvestTime) {
                nCompounds = (rewardEndTime - rewardStartTime) / 1 hours;
                compoundedAmountX96 = compoundedAmountX96.mulX96(
                    Math.compound(rewardRateX96, nCompounds)
                );
                rewardEndTime = rewardStartTime;
            } else {
                nCompounds = (rewardEndTime - lastHarvestTime) / 1 hours;
                compoundedAmountX96 = compoundedAmountX96.mulX96(
                    Math.compound(rewardRateX96, nCompounds)
                );
                break;
            }
        }

        rewardAmount = (compoundedAmountX96 - stakedAmountX96) / Q96;
    }

    function getAllRewardsToHarvest(uint256[] calldata tokenIds)
        public
        view
        returns (uint256 rewardAmount)
    {
        for (uint256 i = 0; i < tokenIds.length; i++) {
            rewardAmount += getRewardsToHarvest(tokenIds[i]);
        }
    }

    function getStakingPower(
        uint256 tokenId,
        uint64 startTime,
        uint64 endTime
    ) public view returns (uint256 stakingPower) {
        require(tokenId <= tokenIdCounter, "not existent");

        StakingInfo storage stakingInfo = stakingInfos[tokenId];
        if (stakingInfo.stakeTime >= endTime || stakingInfo.unstakeTime > 0) {
            return 0;
        }
        if (stakingInfo.stakeTime > startTime) {
            startTime = stakingInfo.stakeTime;
        }

        StakingPlan storage stakingPlan = stakingPlans[stakingInfo.stakingPlan];
        uint256 stakedAmount = stakingInfo.stakedAmount;
        StakingPowerMultiplier[] storage multipliers = stakingPlan.multipliers;
        uint256 i = multipliers.length;
        while (i > 0) {
            i--;

            uint64 rewardStartTime = multipliers[i].startTime;
            uint256 multiplier = multipliers[i].multiplier;

            if (rewardStartTime >= endTime) {
                continue;
            }

            if (rewardStartTime >= startTime) {
                stakingPower +=
                    stakedAmount *
                    (endTime - rewardStartTime) *
                    multiplier;
                endTime = rewardStartTime;
            } else {
                stakingPower +=
                    stakedAmount *
                    (endTime - startTime) *
                    multiplier;
                break;
            }
        }
    }

    function getAllStakingPower(
        uint256[] calldata tokenIds,
        uint64 startTime,
        uint64 endTime
    ) public view returns (uint256 stakingPower) {
        for (uint256 i = 0; i < tokenIds.length; i++) {
            stakingPower += getStakingPower(tokenIds[i], startTime, endTime);
        }
    }

    /* Config Setters */
    function setHarvestStartTime(uint64 _harvestStartTime) external onlyOwner {
        harvestStartTime = _harvestStartTime;
    }

    function setUnstakeLockupPeriod(uint64 _unstakeLockupPeriod)
        external
        onlyOwner
    {
        unstakeLockupPeriod = _unstakeLockupPeriod;
    }

    function setTreasuryAddress(address _treasuryAddress) external onlyOwner {
        treasuryAddress = _treasuryAddress;
    }

    /* Helper Functions */
    function _checkOwnershipOfStakingToken(uint256 tokenId) internal view {
        require(ownerOf(tokenId) == msg.sender, "not owner");
    }

    function _checkStakingPlanActive(bytes32 stakingPlan) internal view {
        require(
            stakingPlans[stakingPlan].deactivatedAt == 0,
            "staking plan not active"
        );
    }

    /* Access Control */
    function addAuthorizedOperator(address account) public onlyOwner {
        authorizedOperators[account] = true;
    }

    function removeAuthorizedOperator(address account) external onlyOwner {
        authorizedOperators[account] = false;
    }

    modifier onlyOperator() {
        require(authorizedOperators[msg.sender], "not authorized");
        _;
    }
}
function getRewardsToHarvest(uint256 tokenId)
    public
    view
    returns (uint256 rewardAmount)
{
    require(tokenId <= tokenIdCounter, "not existent");

    StakingInfo storage stakingInfo = stakingInfos[tokenId];

    if (stakingInfo.unstakeTime > 0) {
        return 0;
    }

    StakingPlan storage stakingPlan = stakingPlans[stakingInfo.stakingPlan];

    // calculate compounded rewards of QLT
    uint256 stakedAmountX96 = stakingInfo.stakedAmount * Q96;
    uint256 compoundedAmountX96 = stakedAmountX96;
    uint64 rewardEndTime = Time.current_hour_timestamp();
    uint64 lastHarvestTime = stakingInfo.lastHarvestTime;

    StakingRewardRate[] storage rewardRates = stakingPlan.rewardRates;
    uint256 i = rewardRates.length;
    while (i > 0) {
        i--;

        uint64 rewardStartTime = rewardRates[i].startTime;
        uint256 rewardRateX96 = rewardRates[i].rewardRateX96;
        uint256 nCompounds;

        if (rewardEndTime < rewardStartTime) {
            continue;
        }

        if (rewardStartTime >= lastHarvestTime) {
            nCompounds = (rewardEndTime - rewardStartTime) / 1 hours;
            compoundedAmountX96 = compoundedAmountX96.mulX96(
                Math.compound(rewardRateX96, nCompounds)
            );
            rewardEndTime = rewardStartTime;
        } else {
            nCompounds = (rewardEndTime - lastHarvestTime) / 1 hours;
            compoundedAmountX96 = compoundedAmountX96.mulX96(
                Math.compound(rewardRateX96, nCompounds)
            );
            break;
        }
    }

    rewardAmount = (compoundedAmountX96 - stakedAmountX96) / Q96;
}

/* Staking State View Functions */