contract NodeManager is Ownable, Pausable {
  using SafeMath for uint256;
  using IterableMapping for IterableMapping.Map;

  struct NodeEntity {
    string name;
    uint256 creationTime;
    uint256 lastClaimTime;
    uint256 amount;
    uint256 tier;
    uint256 totalClaimed;
    uint256 borrowedRewards;
    uint256[3] bondedMolecules; // tokenId of bonded molecules
    uint256 bondedMols; //number of molecules bonded
  }

  IterableMapping.Map private nodeOwners;
  mapping(address => NodeEntity[]) private _nodesOfUser;

  Molecules public molecules;

  address public token;
  uint256 public totalNodesCreated = 0;
  uint256 public totalStaked = 0;
  uint256 public totalClaimed = 0;

  uint256 public levelMultiplier = 250; // bps 250 = 2.5%

  uint256[] public _tiersPrice = [1, 6, 20, 50, 150];
  uint256[] public _tiersRewards = [1250,8000,30000,87500,300000]; // 10000 => 1 OXG
  uint256[] public _boostMultipliers = [102, 105, 110, 130, 200]; // %
  uint256[] public _boostRequiredDays = [35, 56, 84, 183, 365]; // days
  uint256[] public _paperHandsTaxes = [150, 100, 40, 0]; // %; 10 => 1
  uint256[] public _paperHandsWeeks = [1, 2, 3, 4]; // weeks
  uint256[] public _claimTaxFees = [8, 8, 8, 8, 8]; // %, match with tiers



  event NodeCreated(
    address indexed account,
    uint256 indexed blockTime,
    uint256 indexed amount
  );

  event NodeBondedToMolecule(
    address account,
    uint256 tokenID,
    uint256 nodeCreationTime
  );

  event NodeUnbondedToMolecule(
    address account,
    uint256 tokenID,
    uint256 nodeCreationTime
  );

  modifier onlyGuard() {
    require(owner() == _msgSender() || token == _msgSender(), "NOT_GUARD");
    _;
  }

  constructor() {}

  // Private methods


  function _isNameAvailable(address account, string memory nodeName)
    private
    view
    returns (bool)
  {
    NodeEntity[] memory nodes = _nodesOfUser[account];
    for (uint256 i = 0; i < nodes.length; i++) {
      if (keccak256(bytes(nodes[i].name)) == keccak256(bytes(nodeName))) {
        return false;
      }
    }
    return true;
  }

  function _getNodeWithCreatime(
    NodeEntity[] storage nodes,
    uint256 _creationTime
  ) private view returns (NodeEntity storage) {
    uint256 numberOfNodes = nodes.length;
    require(
      numberOfNodes > 0,
      "CASHOUT ERROR: You don't have nodes to cash-out"
    );
    bool found = false;
    int256 index = _binarySearch(nodes, 0, numberOfNodes, _creationTime);
    uint256 validIndex;
    if (index >= 0) {
      found = true;
      validIndex = uint256(index);
    }
    require(found, "NODE SEARCH: No NODE Found with this blocktime");
    return nodes[validIndex];
  }

  function _binarySearch(
    NodeEntity[] memory arr,
    uint256 low,
    uint256 high,
    uint256 x
  ) private view returns (int256) {
    if (high >= low) {
      uint256 mid = (high + low).div(2);
      if (arr[mid].creationTime == x) {
        return int256(mid);
      } else if (arr[mid].creationTime > x) {
        return _binarySearch(arr, low, mid - 1, x);
      } else {
        return _binarySearch(arr, mid + 1, high, x);
      }
    } else {
      return -1;
    }
  }

  
  function _uint2str(uint256 _i)
    private
    pure
    returns (string memory _uintAsString)
  {
    if (_i == 0) {
      return "0";
    }
    uint256 j = _i;
    uint256 len;
    while (j != 0) {
      len++;
      j /= 10;
    }
    bytes memory bstr = new bytes(len);
    uint256 k = len;
    while (_i != 0) {
      k = k - 1;
      uint8 temp = (48 + uint8(_i - (_i / 10) * 10));
      bytes1 b1 = bytes1(temp);
      bstr[k] = b1;
      _i /= 10;
    }
    return string(bstr);
  }

  function _calculateNodeRewards(
    uint256 _lastClaimTime,
    uint256 _tier
  ) private view returns (uint256 rewards) {
    uint256 elapsedTime_ = (block.timestamp - _lastClaimTime);
    uint256 boostMultiplier = _calculateBoost(elapsedTime_);
    uint256 rewardPerMonth = _tiersRewards[_tier];
    return
      rewardPerMonth.mul(1e18).div(2628000).mul(elapsedTime_).mul(boostMultiplier).div(100).div(10000);
  }

  function _calculateBoost(uint256 elapsedTime_)
    internal
    view
    returns (uint256)
  {
    uint256 elapsedTimeInDays_ = elapsedTime_ / 1 days;

    if (elapsedTimeInDays_ >= _boostRequiredDays[4]) {
      return _boostMultipliers[4];
    } else if (elapsedTimeInDays_ >= _boostRequiredDays[3]) {
      return _boostMultipliers[3];
    } else if (elapsedTimeInDays_ >= _boostRequiredDays[2]) {
      return _boostMultipliers[2];
    } else if (elapsedTimeInDays_ >= _boostRequiredDays[1]) {
      return _boostMultipliers[1];
    } else if (elapsedTimeInDays_ >= _boostRequiredDays[0]) {
      return _boostMultipliers[0];
    } else {
      return 100;
    }
  }

  // External methods

   function upgradeNode(address account, uint256 blocktime) 
    external
    onlyGuard
    whenNotPaused
    {
        require(blocktime > 0, "NODE: CREATIME must be higher than zero");
        NodeEntity[] storage nodes = _nodesOfUser[account];
        require(
            nodes.length > 0,
            "CASHOUT ERROR: You don't have nodes to cash-out"
            );
        NodeEntity storage node = _getNodeWithCreatime(nodes, blocktime);
        node.tier += 1;
    }

    function borrowRewards(address account, uint256 blocktime, uint256 amount)
    external
    onlyGuard
    whenNotPaused
    {
        require(blocktime > 0, "NODE: blocktime must be higher than zero");
        NodeEntity[] storage nodes = _nodesOfUser[account];
        require(
            nodes.length > 0,
            "You don't have any nodes"
        );
        NodeEntity storage node = _getNodeWithCreatime(nodes, blocktime);
        uint256 rewardsAvailable = _calculateNodeRewards(node.lastClaimTime, node.tier).sub(node.borrowedRewards);
        require(rewardsAvailable >= amount,"You do not have enough rewards available");
        node.borrowedRewards += amount;
    }

  function createNode(
    address account,
    string memory nodeName,
    uint256 _tier
  ) external onlyGuard whenNotPaused {
    require(_isNameAvailable(account, nodeName), "Name not available");
    NodeEntity[] storage _nodes = _nodesOfUser[account];
    require(_nodes.length <= 100, "Max nodes exceeded");
    uint256 amount = getNodePrice(_tier);
    _nodes.push(
      NodeEntity({
        name: nodeName,
        creationTime: block.timestamp,
        lastClaimTime: block.timestamp,
        amount: amount,
        tier: _tier,
        totalClaimed: 0,
        borrowedRewards: 0,
        bondedMolecules: [uint256(0),0,0],
        bondedMols: 0
      })
    );
    nodeOwners.set(account, _nodesOfUser[account].length);
    emit NodeCreated(account, block.timestamp, amount);
    totalNodesCreated++;
    totalStaked += amount;
  }

  function getNodeReward(address account, uint256 _creationTime)
    public
    view
    returns (uint256)
  {
    require(_creationTime > 0, "NODE: CREATIME must be higher than zero");
    NodeEntity[] storage nodes = _nodesOfUser[account];
    require(
      nodes.length > 0,
      "CASHOUT ERROR: You don't have nodes to cash-out"
    );
    NodeEntity storage node = _getNodeWithCreatime(nodes, _creationTime);
    return _calculateNodeRewards(node.lastClaimTime, node.tier).mul(getNodeAPRIncrease(account, _creationTime)).div(10000).sub(node.borrowedRewards);
  }

  function getAllNodesRewards(address account) external view returns (uint256[2] memory) {
    NodeEntity[] storage nodes = _nodesOfUser[account];
    uint256 nodesCount = nodes.length;
    require(nodesCount > 0, "NODE: CREATIME must be higher than zero");
    NodeEntity storage _node;
    uint256 rewardsTotal = 0;
    uint256 taxTotal = 0;
    for (uint256 i = 0; i < nodesCount; i++) {
      _node = nodes[i];
      uint256 nodeReward =  _calculateNodeRewards(
        _node.lastClaimTime,
        _node.tier
      ).sub(_node.borrowedRewards);
      nodeReward = nodeReward;
      taxTotal += getNodeFee(account, _node.creationTime, nodeReward);
      rewardsTotal += nodeReward;
    }
    return [rewardsTotal, taxTotal];
  }

  function cashoutNodeReward(address account, uint256 _creationTime)
    external
    onlyGuard
    whenNotPaused
  {
    require(_creationTime > 0, "NODE: CREATIME must be higher than zero");
    NodeEntity[] storage nodes = _nodesOfUser[account];
    require(
      nodes.length > 0,
      "CASHOUT ERROR: You don't have nodes to cash-out"
    );
    NodeEntity storage node = _getNodeWithCreatime(nodes, _creationTime);
    uint256 toClaim = _calculateNodeRewards(
      node.lastClaimTime,
      node.tier
    ).sub(node.borrowedRewards);
    node.totalClaimed += toClaim;
    node.lastClaimTime = block.timestamp;
    node.borrowedRewards = 0;
  }

  function cashoutAllNodesRewards(address account)
    external
    onlyGuard
    whenNotPaused 
  {
    NodeEntity[] storage nodes = _nodesOfUser[account];
    uint256 nodesCount = nodes.length;
    require(nodesCount > 0, "NODE: CREATIME must be higher than zero");
    NodeEntity storage _node;
    for (uint256 i = 0; i < nodesCount; i++) {
      _node = nodes[i];  
      uint256 toClaim = _calculateNodeRewards(
        _node.lastClaimTime,
        _node.tier
      ).sub(_node.borrowedRewards);
      _node.totalClaimed += toClaim;
      _node.lastClaimTime = block.timestamp;
      _node.borrowedRewards = 0;
    }
  }

  function setMoleculeAddress(address _moleculesAddress) external onlyOwner {
      molecules = Molecules(_moleculesAddress);
    }



  function bondNFT(uint256 _creationTime, uint256 _tokenId) external {
    address account = _msgSender();
    require(_creationTime > 0, "NODE: CREATIME must be higher than zero");
    NodeEntity[] storage nodes = _nodesOfUser[account];
    require(
      nodes.length > 0,
      "You don't own any nodes"
    );
    NodeEntity storage node = _getNodeWithCreatime(nodes, _creationTime);
    require(node.bondedMols < 3,"Already bonded to enough molecules");
    molecules.bondMolecule(account, _tokenId, node.creationTime);
    node.bondedMolecules[node.bondedMols] = _tokenId;
    node.bondedMols += 1;
    emit NodeBondedToMolecule(account, _tokenId, _creationTime);
  }

  // function to unbond NFT 

  function unbondNFT(uint256 _creationTime, uint256 _tokenId) external {
    address account = _msgSender();
    require(_creationTime > 0, "NODE: CREATIME must be higher than zero");
    NodeEntity[] storage nodes = _nodesOfUser[account];
    require(
      nodes.length > 0,
      "You don't own any nodes"
    );
    NodeEntity storage node = _getNodeWithCreatime(nodes, _creationTime);
    require(node.bondedMols > 0,"No Molecules Bonded");
    molecules.unbondMolecule(account, _tokenId, node.creationTime);
    uint256[3] memory newArray = [uint256(0),0,0];
    for (uint256 i = 0 ; i < node.bondedMols; i++) {
        if (node.bondedMolecules[i] != _tokenId) {
          newArray[i] = node.bondedMolecules[i];
        }
    }
    node.bondedMolecules = newArray;
    node.bondedMols -= 1;
    emit NodeUnbondedToMolecule(account, _tokenId, _creationTime);
  }

  function getNodesNames(address account) public view returns (string memory) {
    NodeEntity[] memory nodes = _nodesOfUser[account];
    uint256 nodesCount = nodes.length;
    NodeEntity memory _node;
    string memory names = nodes[0].name;
    string memory separator = "#";
    for (uint256 i = 1; i < nodesCount; i++) {
      _node = nodes[i];
      names = string(abi.encodePacked(names, separator, _node.name));
    }
    return names;
  }

  function getNodesRewards(address account) public view returns (string memory) {
    NodeEntity[] memory nodes = _nodesOfUser[account];
    uint256 nodesCount = nodes.length;
    NodeEntity memory _node;
    string memory rewards = _uint2str(_calculateNodeRewards(nodes[0].lastClaimTime, nodes[0].tier).mul(getNodeAPRIncrease(account, nodes[0].creationTime)).div(10000).sub(nodes[0].borrowedRewards));
    string memory separator = "#";
    for (uint256 i = 1; i < nodesCount; i++) {
      _node = nodes[i];
      string memory _rewardStr = _uint2str(_calculateNodeRewards(_node.lastClaimTime, _node.tier).mul(getNodeAPRIncrease(account, _node.creationTime)).div(10000).sub(_node.borrowedRewards));
      rewards = string(abi.encodePacked(rewards, separator, _rewardStr));
    }
    return rewards;
  }

  function getNodesCreationTime(address account)
    public
    view
    returns (string memory)
  {
    NodeEntity[] memory nodes = _nodesOfUser[account];
    uint256 nodesCount = nodes.length;
    NodeEntity memory _node;
    string memory _creationTimes = _uint2str(nodes[0].creationTime);
    string memory separator = "#";

    for (uint256 i = 1; i < nodesCount; i++) {
      _node = nodes[i];

      _creationTimes = string(
        abi.encodePacked(
          _creationTimes,
          separator,
          _uint2str(_node.creationTime)
        )
      );
    }
    return _creationTimes;
  }

  function getNodeAPRIncrease(address account, uint256 _creationTime) public view returns (uint256){
    require(_creationTime > 0, "NODE: CREATIME must be higher than zero");
    NodeEntity[] storage nodes = _nodesOfUser[account];
    require(
      nodes.length > 0,
      "You don't own any nodes"
    );
    NodeEntity storage node = _getNodeWithCreatime(nodes, _creationTime);
    if (node.bondedMols == 0){
      uint256 totalApyBenefit = 10000;
      return totalApyBenefit;
    }
    else {
      uint256 totalApyBenefit = 0;
      for (uint256 i = 0; i < node.bondedMols; i++) {
        if (molecules.getMoleculeKind(node.bondedMolecules[i]) == 2 || molecules.getMoleculeKind(node.bondedMolecules[i]) == 3) {
          uint256 APYBenefit = molecules.getMoleculeLevel(node.bondedMolecules[i]).mul(levelMultiplier).add(250);
          totalApyBenefit += APYBenefit;
        }
      }
      totalApyBenefit += 10000;
      return totalApyBenefit;
    }
  }

  
  function getNodeTaxDecrease(address account, uint256 _creationTime) public view returns (uint256){
    require(_creationTime > 0, "NODE: CREATIME must be higher than zero");
    NodeEntity[] storage nodes = _nodesOfUser[account];
    require(
      nodes.length > 0,
      "You don't own any nodes"
    );
    NodeEntity storage node = _getNodeWithCreatime(nodes, _creationTime);
    if (node.bondedMols == 0){
      uint256 totalTaxDecrease = 0;
      return totalTaxDecrease;
    }
    else {
      uint256 totalTaxDecrease = 0;
      for (uint256 i = 0; i < node.bondedMols; i++) {
        if (molecules.getMoleculeKind(node.bondedMolecules[i]) == 1 || molecules.getMoleculeKind(node.bondedMolecules[i]) == 3) {
          uint256 APYBenefit = molecules.getMoleculeLevel(node.bondedMolecules[i]).mul(levelMultiplier).add(250);
          totalTaxDecrease += APYBenefit;
        }
      }
      if (totalTaxDecrease > 10000) {
        totalTaxDecrease = 10000;
      }
      return totalTaxDecrease;
    }
  }


  function getNodesLastClaimTime(address account)
    public
    view
    returns (string memory)
  {
    NodeEntity[] memory nodes = _nodesOfUser[account];
    uint256 nodesCount = nodes.length;
    NodeEntity memory _node;
    string memory _lastClaimTimes = _uint2str(nodes[0].lastClaimTime);
    string memory separator = "#";

    for (uint256 i = 1; i < nodesCount; i++) {
      _node = nodes[i];

      _lastClaimTimes = string(
        abi.encodePacked(
          _lastClaimTimes,
          separator,
          _uint2str(_node.lastClaimTime)
        )
      );
    }
    return _lastClaimTimes;
  }

  function getNodeFee(
    address account,
    uint256 _creationTime,
    uint256 _rewardsAmount
  ) public view returns (uint256) {
    require(_creationTime > 0, "NODE: CREATIME must be higher than zero");
    NodeEntity[] storage nodes = _nodesOfUser[account];
    require(
      nodes.length > 0,
      "CASHOUT ERROR: You don't have nodes to cash-out"
    );
    NodeEntity storage node = _getNodeWithCreatime(nodes, _creationTime);

    uint256 paperHandsTax = 0;
    uint256 claimTx = _rewardsAmount.mul(_claimTaxFees[node.tier]).div(100);

    uint256 elapsedSeconds = block.timestamp - node.lastClaimTime;

    if (elapsedSeconds >= _paperHandsWeeks[3].mul(86400).mul(7)) {
      paperHandsTax = _rewardsAmount.mul(_paperHandsTaxes[3]).div(1000);
    } else if (elapsedSeconds >= _paperHandsWeeks[2].mul(86400).mul(7)) {
      paperHandsTax = _rewardsAmount.mul(_paperHandsTaxes[2]).div(1000);
    } else if (elapsedSeconds >= _paperHandsWeeks[1].mul(86400).mul(7)) {
      paperHandsTax = _rewardsAmount.mul(_paperHandsTaxes[1]).div(1000);
    } else if (elapsedSeconds >= _paperHandsWeeks[0].mul(86400).mul(7)) {
      paperHandsTax = _rewardsAmount.mul(_paperHandsTaxes[0]).div(1000);
    } else {
      paperHandsTax = _rewardsAmount.mul(200).div(1000);
    }
    uint256 totalTax = claimTx.add(paperHandsTax);
    uint256 taxRebate = totalTax.mul(getNodeTaxDecrease(account,_creationTime)).div(10000);

    return totalTax.sub(taxRebate);
  }

  function updateToken(address newToken) external onlyOwner {
    token = newToken;
  }

  function updateTiersRewards(uint256[] memory newVal) external onlyOwner {
    require(newVal.length == 5, "Wrong length");
    _tiersRewards = newVal;
  }

  function updateTiersPrice(uint256[] memory newVal) external onlyOwner {
    require(newVal.length == 5, "Wrong length");
    _tiersPrice = newVal;
  }

  function updateBoostMultipliers(uint8[] calldata newVal) external onlyOwner {
    require(newVal.length == 5, "Wrong length");
    _boostMultipliers = newVal;
  }

  function updateBoostRequiredDays(uint8[] calldata newVal) external onlyOwner {
    require(newVal.length == 5, "Wrong length");
    _boostRequiredDays = newVal;
  }

  function getNodeTier(address account, uint256 blocktime) public view returns (uint256) {
    require(blocktime > 0, "Creation Time has to be higher than 0");
    require(isNodeOwner(account), "NOT NODE OWNER");
    NodeEntity[] storage nodes = _nodesOfUser[account];
    uint256 numberOfNodes = nodes.length;
    require(
        numberOfNodes > 0,
        "You don't own any nodes."
    );
    NodeEntity storage node = _getNodeWithCreatime(nodes, blocktime);
    return node.tier;
  }

  function getNodePrice(uint256 _tierIndex) public view returns (uint256) {
    return _tiersPrice[_tierIndex];
  }

  function getNodeNumberOf(address account) external view returns (uint256) {
    return nodeOwners.get(account);
  }

  function isNodeOwner(address account) public view returns (bool) {
    return nodeOwners.get(account) > 0;
  }

  function getNodeMolecules(address account, uint256 blocktime) public view returns (uint256[3] memory) {
    require(blocktime > 0, "Creation Time has to be higher than 0");
    require(isNodeOwner(account), "NOT NODE OWNER");
    NodeEntity[] storage nodes = _nodesOfUser[account];
    uint256 numberOfNodes = nodes.length;
    require(
        numberOfNodes > 0,
        "You don't own any nodes."
    );
    NodeEntity storage node = _getNodeWithCreatime(nodes, blocktime);
    return node.bondedMolecules;
  }

  function getAllNodes(address account)
    external
    view
    returns (NodeEntity[] memory)
  {
    return _nodesOfUser[account];
  }

  function getIndexOfKey(address account)
    external
    view
    onlyOwner
    returns (int256)
  {
    require(account != address(0));
    return nodeOwners.getIndexOfKey(account);
  }

  function burn(uint256 index) external onlyOwner {
    require(index < nodeOwners.size());
    nodeOwners.remove(nodeOwners.getKeyAtIndex(index));
  }

  // User Methods

  function changeNodeName(uint256 _creationTime, string memory newName) 
    public 
    {
        address sender = msg.sender;
        require(isNodeOwner(sender), "NOT NODE OWNER");
        NodeEntity[] storage nodes = _nodesOfUser[sender];
        uint256 numberOfNodes = nodes.length;
        require(
            numberOfNodes > 0,
            "You don't own any nodes."
        );
        NodeEntity storage node = _getNodeWithCreatime(nodes, _creationTime);
        node.name = newName;
    }


  // Firewall methods

  function pause() external onlyOwner {
    _pause();
  }

  function unpause() external onlyOwner {
    _unpause();
  }
}
function unbondNFT(uint256 _creationTime, uint256 _tokenId) external {
  address account = _msgSender();
  require(_creationTime > 0, "NODE: CREATIME must be higher than zero");
  NodeEntity[] storage nodes = _nodesOfUser[account];
  require(
    nodes.length > 0,
    "You don't own any nodes"
  );
  NodeEntity storage node = _getNodeWithCreatime(nodes, _creationTime);
  require(node.bondedMols > 0,"No Molecules Bonded");
  molecules.unbondMolecule(account, _tokenId, node.creationTime);
  uint256[3] memory newArray = [uint256(0),0,0];
  for (uint256 i = 0 ; i < node.bondedMols; i++) {
      if (node.bondedMolecules[i] != _tokenId) {
        newArray[i] = node.bondedMolecules[i];
      }
  }
  node.bondedMolecules = newArray;
  node.bondedMols -= 1;
  emit NodeUnbondedToMolecule(account, _tokenId, _creationTime);
}

// function to unbond NFT 