contract Achievements is ERC165, ERC721SimpleMapsURI, CollectionNotificationI, AchievementsUpgradingI {
    using SafeMath for uint256;

    enum AchievementCategory {
        TOTAL,
        COLOR,
        ALLANIMALS,
        ALLCOLORS,
        ALL
    }

    bytes4 private constant _INTERFACE_ID_COLLECTION_NOTIFICATION = 0x49ae07c9;
    bytes4 private constant _INTERFACE_ID_ACHIEVEMENTS_UPGRADING = 0x58cac597;

    address public CS1Address;
    address public CS1ColorsAddress;
    address public CS2Address;
    address public collectionsAddress;
    address public tokenAssignmentControl;

    uint8 public constant totalTypes = 5;
    uint8 public constant totalColors = 5;
    uint public constant confirmationTime = 2 days;
    uint8[5] public colorWeight; // quasi-constant, as array constants are not supported, set in constructor.

    // For every collection, for every token type, for every color, a count.
    mapping(address => mapping(uint256 => mapping(uint256 => uint256))) public assetCount;
    // For every collection, for every token type, a count.
    mapping(address => mapping(uint256 => uint256)) public assetTypeCount;
    // For every collection, a total count.
    mapping(address => uint256) public assetTotalCount;
    // For every collection, see if achievement of specified code was awarded (pointing to token ID, so you need to reverse-try, esp. for ID 0)
    mapping(address => mapping(uint256 => uint256)) public awardedTokenId;
    // For every collection, note if it has been completely revoked.
    mapping(address => bool) public allAchievementsRevoked;

    // Token properties (for every token ID)
    mapping(uint256 => uint256) public achievementCode;
    mapping(uint256 => uint) public creationTime;

    event AchievementAwarded(address indexed owner, uint256 achievementCode, uint256 tokenId);
    event AchievementRevoked(address indexed owner, uint256 achievementCode, uint256 tokenId);
    event AllAchievementsRevoked(address indexed owner);
    event TokenAssignmentControlTransferred(address indexed previousTokenAssignmentControl, address indexed newTokenAssignmentControl);

    constructor(address _CS1Address, address _CS1ColorsAddress, address _CS2Address, address _collectionsAddress, address _tokenAssignmentControl)
    ERC721SimpleMapsURI("Crypto stamp 2 Achievements", "CS2A", "https://test.crypto.post.at/CS2A/meta/")
    public
    {
        _registerInterface(_INTERFACE_ID_COLLECTION_NOTIFICATION);
        _registerInterface(_INTERFACE_ID_ACHIEVEMENTS_UPGRADING);
        CS1Address = _CS1Address;
        CS1ColorsAddress = _CS1ColorsAddress;
        CS2Address = _CS2Address;
        collectionsAddress = _collectionsAddress;
        tokenAssignmentControl = _tokenAssignmentControl;
        // Set quasi-constant array values: point weights for colors.
        colorWeight = [1, 2, 4, 8, 20];
    }

    modifier onlyTokenAssignmentControl() {
        require(msg.sender == tokenAssignmentControl, "tokenAssignmentControl key required for this function.");
        _;
    }

    /*** Enable adjusting variables after deployment ***/

    function transferTokenAssignmentControl(address _newTokenAssignmentControl)
    public
    onlyTokenAssignmentControl
    {
        require(_newTokenAssignmentControl != address(0), "tokenAssignmentControl cannot be the zero address.");
        emit TokenAssignmentControlTransferred(tokenAssignmentControl, _newTokenAssignmentControl);
        tokenAssignmentControl = _newTokenAssignmentControl;
    }

    // Set new base for the token URI.
    function setBaseURI(string memory _newBaseURI)
    public
    onlyTokenAssignmentControl
    {
        super._setBaseURI(_newBaseURI);
    }

    /*** Handle assets being added and removed on a collection ***/

    // This is being called when a collection adds this one as a notification contract.
    function onContractAdded(bool _initial)
    external override
    returns (bytes4) {
        // Just make sure that this is a valid collection and is empty.
        ERC721ExistsI collections = ERC721ExistsI(collectionsAddress);
        require(collections.exists(uint256(msg.sender)), "Sender needs to be a valid Collection.");
        if (!_initial) {
            // When called from the constructor, we cannot call the contract back, but we know it's empty.
            // When called later, we need to verify that it's empty.
            require(CollectionOwnedI(msg.sender).ownedAssetsCount() == 0, "Collection needs to be empty.");
        }
        return this.onContractAdded.selector;
    }

    // Called when this contract is removed as a notification contract from a collection.
    function onContractRemoved()
    external override
    returns (bytes4) {
        // Revoke all achievements.
        for (uint i = 1; i <= totalTypes; i++) { // 1-based, so use <=
            for (uint j = 0; j < totalColors; j++) {
                assetCount[msg.sender][i][j] = 0;
            }
            assetTypeCount[msg.sender][i] = 0;
        }
        assetTotalCount[msg.sender] = 0;
        allAchievementsRevoked[msg.sender] = true;
        emit AllAchievementsRevoked(msg.sender);
        return this.onContractRemoved.selector;
    }

    // This function is called when an asset is added to a collection's tracked list, either by transfer or by "sync".
    function onAssetAdded(address _tokenAddress, uint256 _tokenId, CollectionNotificationI.TokenType _tokenType)
    external override
    returns (bytes4)
    {
        if (_tokenType == CollectionNotificationI.TokenType.ERC721) {
            // We only look at ERC721 tokens for achievements.
            require(IERC721(_tokenAddress).ownerOf(_tokenId) == msg.sender, "NFT needs to be owned by the caller of this function.");
            uint256 typeNum = getTypeNum(_tokenAddress, _tokenId);
            if (typeNum > 0) {
                // We have a tracked asset.
                ERC721ExistsI collections = ERC721ExistsI(collectionsAddress);
                require(collections.exists(uint256(msg.sender)), "Sender needs to be a valid Collection.");
                uint256 colorNum = getColorNum(_tokenAddress, _tokenId);
                assetCount[msg.sender][typeNum][colorNum] = assetCount[msg.sender][typeNum][colorNum].add(1);
                assetTypeCount[msg.sender][typeNum] = assetTypeCount[msg.sender][typeNum].add(1);
                assetTotalCount[msg.sender] = assetTotalCount[msg.sender].add(1);
                // We maybe need to award new or reactivate existing achievements here.
                _checkAndAwardAchievements(msg.sender, typeNum, colorNum);
            }
        }
        return this.onAssetAdded.selector;
    }

    // This function is called when an asset is removed from a collection's tracked list, either by transfer or by "sync".
    function onAssetRemoved(address _tokenAddress, uint256 _tokenId, CollectionNotificationI.TokenType _tokenType)
    external override
    returns (bytes4)
    {
        if (_tokenType == CollectionNotificationI.TokenType.ERC721) {
            // We only care about ERC721 tokens.
            require(IERC721(_tokenAddress).ownerOf(_tokenId) != msg.sender, "NFT needs not to be owned by the caller of this function.");
            uint256 typeNum = getTypeNum(_tokenAddress, _tokenId);
            if (typeNum > 0) {
                // We have a tracked asset.
                uint256 colorNum = getColorNum(_tokenAddress, _tokenId);
                assetCount[msg.sender][typeNum][colorNum] = assetCount[msg.sender][typeNum][colorNum].sub(1);
                assetTypeCount[msg.sender][typeNum] = assetTypeCount[msg.sender][typeNum].sub(1);
                assetTotalCount[msg.sender] = assetTotalCount[msg.sender].sub(1);
                // We maybe need to disable achievements here.
                _checkAndRevokeAchievements(msg.sender, typeNum, colorNum);
            }
        }
        return this.onAssetRemoved.selector;
    }

    // This function is called when an asset changes its color (see CS2 "upgrading"). Needs to be called by the CS2 contract.
    function onCS2ColorChanged(uint256 _tokenId, CS2PropertiesI.Colors _previousColor, CS2PropertiesI.Colors _newColor)
    external override
    returns (bytes4)
    {
        require(msg.sender == CS2Address, "Needs to be called by CS2 contract.");
        address owner = IERC721(CS2Address).ownerOf(_tokenId);
        uint256 typeNum = getTypeNum(CS2Address, _tokenId);
        uint256 prevColorNum = uint256(_previousColor);
        uint256 newColorNum = uint256(_newColor);

        // Remove this token from previous color count.
        assetCount[owner][typeNum][prevColorNum] = assetCount[owner][typeNum][prevColorNum].sub(1);
        // We maybe need to disable achievements here.
        _checkAndRevokeAchievements(owner, typeNum, prevColorNum);

        // Add this token to new color count.
        assetCount[owner][typeNum][newColorNum] = assetCount[owner][typeNum][newColorNum].add(1);
        // We maybe need to award new or reactivate existing achievements here.
        _checkAndAwardAchievements(owner, typeNum, newColorNum);

        return this.onCS2ColorChanged.selector;
    }

    // Check if any achievements need to be awarded and if that's the case, make it so.
    function _checkAndAwardAchievements(address _collAddr, uint256 _typeNum, uint256 _colorNum)
    internal
    {
        // Determine how many stamps of this color the collection owns.
        uint256 thisColorCount = 0;
        for (uint i = 1; i <= totalTypes; i++) { // 1-based, so use <=
            thisColorCount = thisColorCount.add(assetCount[_collAddr][i][_colorNum]);
        }
        // Check if we need to award new achievements.
        if (assetCount[_collAddr][_typeNum][_colorNum] == 1) {
            // Check for achievements that can be awarded when getting the first stamp of a sort.
            uint256 aCode = getAchievementCode(AchievementCategory.ALLCOLORS, _typeNum, 0, 1);
            if (!hasAchievement(_collAddr, aCode)) {
                bool hasAllColors = true;
                for (uint i = 0; i < totalColors; i++) {
                    if (assetCount[_collAddr][_typeNum][i] < 1) {
                        hasAllColors = false;
                    }
                }
                if (hasAllColors) {
                    _createAchievement(_collAddr, aCode); // ALLCOLORS-{type}

                    // If we get the last of the ALLCOLORS, we may get the overall ALL achievement as well!
                    aCode = getAchievementCode(AchievementCategory.ALL, 0, 0, 1);
                    if (!hasAchievement(_collAddr, aCode)) {
                        bool hasAllTypes = true;
                        for (uint i = 1; i <= totalTypes; i++) { // 1-based, so use <=
                            uint256 aTestCode = getAchievementCode(AchievementCategory.ALLCOLORS, i, 0, 1);
                            if (!hasAchievement(_collAddr, aTestCode)) {
                                hasAllTypes = false;
                            }
                        }
                        if (hasAllTypes) {
                            _createAchievement(_collAddr, aCode); // ALL
                        }
                    }
                }
            }
        }
        if (thisColorCount == 5 ||
            thisColorCount == 10 ||
            (thisColorCount == 50 && _colorNum == uint256(CS2PropertiesI.Colors.Blue))) {
            // Check for achievements that can be awarded when getting to 5, 10, or 50 stamps of that color.
            uint256 aCode = getAchievementCode(AchievementCategory.COLOR, 0, _colorNum, thisColorCount);
            if (!hasAchievement(_collAddr, aCode)) {
                _createAchievement(_collAddr, aCode); // COLOR-{color}-{5,10,50}
            }
        }
        if (assetTypeCount[_collAddr][_typeNum] == 1 ||
            assetTypeCount[_collAddr][_typeNum] == 5 ||
            assetTypeCount[_collAddr][_typeNum] == 10) {
            // Check for achievements that can be awarded when getting to 1,5, or 10 stamps of every type.
            uint256 aCode = getAchievementCode(AchievementCategory.ALLANIMALS, 0, 0, assetTypeCount[_collAddr][_typeNum]);
            if (!hasAchievement(_collAddr, aCode)) {
                bool hasAllTypes = true;
                for (uint i = 1; i <= totalTypes; i++) { // 1-based, so use <=
                    if (assetTypeCount[_collAddr][i] < assetTypeCount[_collAddr][_typeNum]) {
                        hasAllTypes = false;
                    }
                }
                if (hasAllTypes) {
                    _createAchievement(_collAddr, aCode); // ALLANIMALS-{1,5,10}
                }
            }
        }
        if (assetTotalCount[_collAddr] == 1 ||
            assetTotalCount[_collAddr] == 5 ||
            assetTotalCount[_collAddr] == 10) {
            // Check for achievements that can be awarded when getting to 1, 5, or 10 total stamps.
            uint256 aCode = getAchievementCode(AchievementCategory.TOTAL, 0, 0, assetTotalCount[_collAddr]);
            if (!hasAchievement(_collAddr, aCode)) {
                _createAchievement(_collAddr, aCode); // TOTAL-{1,5,10}
            }
        }
    }

    // Check if any achievements need to be revoked and if that's the case, make it so.
    function _checkAndRevokeAchievements(address _collAddr, uint256 _typeNum, uint256 _colorNum)
    internal
    {
        // Determine how many stamps of this color the collection owns.
        uint256 thisColorCount = 0;
        for (uint i = 1; i <= totalTypes; i++) { // 1-based, so use <=
            thisColorCount = thisColorCount.add(assetCount[_collAddr][i][_colorNum]);
        }
        // Check if we need to un-award any achievements.
        if (assetCount[_collAddr][_typeNum][_colorNum] == 0) {
            // Check for achievements that need to be revoked due to removing the last stamp of a sort.
            // We definitely have lost at least one color for this type: ALLCOLORS-{type}
            _disableAchievementIfApplicable(_collAddr, getAchievementCode(AchievementCategory.ALLCOLORS, _typeNum, 0, 1));
            // As we have lost one color/type combination, revoke this as well: ALL
            _disableAchievementIfApplicable(_collAddr, getAchievementCode(AchievementCategory.ALL, 0, 0, 1));
        }
        if (thisColorCount == 4 ||
            thisColorCount == 9 ||
            (thisColorCount == 49 && _colorNum == uint256(CS2PropertiesI.Colors.Blue))) {
            // Check for achievements that were awarded when getting to 5, 10, or 50 stamps of that color: COLOR-{color}-{5,10,50}
            _disableAchievementIfApplicable(_collAddr,
                getAchievementCode(AchievementCategory.COLOR, 0, _colorNum, thisColorCount.add(1)));
        }
        if (assetTypeCount[_collAddr][_typeNum] == 0 ||
            assetTypeCount[_collAddr][_typeNum] == 4 ||
            assetTypeCount[_collAddr][_typeNum] == 9) {
            // Check for achievements that were awarded when getting to 1,5, or 10 stamps of every type: ALLANIMALS-{1,5,10}
            _disableAchievementIfApplicable(_collAddr,
                getAchievementCode(AchievementCategory.ALLANIMALS, 0, 0, assetTypeCount[_collAddr][_typeNum].add(1)));
        }
        if (assetTotalCount[_collAddr] == 0 ||
            assetTotalCount[_collAddr] == 4 ||
            assetTotalCount[_collAddr] == 9) {
            // Check for achievements that were awarded when getting to 1, 5, or 10 total stamps: TOTAL-{1,5,10}
            _disableAchievementIfApplicable(_collAddr,
                getAchievementCode(AchievementCategory.TOTAL, 0, 0, assetTotalCount[_collAddr].add(1)));
        }
    }

    // Create new achievement or reactivate existing one, as needed.
    function _createAchievement(address _newOwner, uint256 _achievementCode)
    internal
    {
        uint256 potentialTokenId = awardedTokenId[_newOwner][_achievementCode];
        if (_exists(potentialTokenId) &&
            achievementCode[potentialTokenId] == _achievementCode &&
            ownerOf(potentialTokenId) == _newOwner) {
            // Achievement token already exists, reactivate it.
            creationTime[potentialTokenId] = now;
            emit AchievementAwarded(_newOwner, _achievementCode, potentialTokenId);
        }
        else {
            // Create new achievement token, current totalSupply() is the new token ID.
            // _safeMint() checks if the recipient can actually receive ERC721 tokens.
            uint256 newTokenId = totalSupply();
            _safeMint(_newOwner, newTokenId);
            achievementCode[newTokenId] = _achievementCode;
            creationTime[newTokenId] = now;
            awardedTokenId[_newOwner][_achievementCode] = newTokenId;
            emit AchievementAwarded(_newOwner, _achievementCode, newTokenId);
        }
    }

    // Deactivate achievement, if the owner actually owns it and it is not confimed.
    function _disableAchievementIfApplicable(address _owner, uint256 _achievementCode)
    internal
    {
        uint256 potentialTokenId = awardedTokenId[_owner][_achievementCode];
        if (hasAchievement(_owner, _achievementCode) && !isConfirmed(potentialTokenId)) {
            creationTime[potentialTokenId] = 0;
            emit AchievementRevoked(_owner, _achievementCode, potentialTokenId);
        }
    }

    //*** Getters for token properties that are not explicit public variables ***

    // Returns whether the specified token exists.
    function exists(uint256 tokenId) public view returns (bool) {
        return _exists(tokenId);
    }

    // Returns whether the specified achievement has been confirmed.
    // This is the case if creationTime is not 0 but is older than confirmationTime.
    function isConfirmed(uint256 _tokenId) public view returns (bool) {
        // Compare creationTime to now before subtraction to avoid underflow.
        return (creationTime[_tokenId] > 0 && creationTime[_tokenId] < now &&
                (now - creationTime[_tokenId]) > confirmationTime);
    }

    // Returns whether the specified achievement is actually active.
    // This is the case if creationTime is not 0 but equals now or is in the past,
    // AND if either allAchievementsRevoked is false or it's a confirmed achievement.
    function isActive(uint256 _tokenId) public view returns (bool) {
        return (creationTime[_tokenId] > 0 && creationTime[_tokenId] <= now &&
                (allAchievementsRevoked[ownerOf(_tokenId)] == false ||
                 (now - creationTime[_tokenId]) > confirmationTime));
    }

    // Returns the points for the specified achievement.
    function getPoints(uint256 _tokenId) public view returns (uint256) {
        return getPointsByCode(achievementCode[_tokenId]);
    }

    //*** Functions (pure or view) around handling achievement code and points ***

    // Encode achievement code from details.
    function getAchievementCode(AchievementCategory _category, uint256 _typeNum, uint256 _colorNum, uint256 _count)
    internal pure
    returns (uint256)
    {
        return uint256(_category).mul(2 ** 30).add(_typeNum.mul(2 ** 20)).add(_colorNum.mul(2 ** 10)).add(_count);
    }

    // Decode details from achievement code.
    function getDetailsFromCode(uint256 _achievementCode)
    public pure
    returns (AchievementCategory, uint256, uint256, uint256)
    {
        uint256 count = _achievementCode % (2 ** 10);
        uint256 colorNum = (_achievementCode >> 10) % (2 ** 10);
        uint256 typeNum = (_achievementCode >> 20) % (2 ** 10);
        AchievementCategory category = AchievementCategory((_achievementCode >> 30));
        return (category, typeNum, colorNum, count);
    }

    // Determine if a collection actually has a given achievement and it is active.
    function hasAchievement(address _owner, uint256 _achievementCode)
    public view
    returns (bool)
    {
        uint256 potentialTokenId = awardedTokenId[_owner][_achievementCode];
        return (_exists(potentialTokenId) &&
                achievementCode[potentialTokenId] == _achievementCode &&
                ownerOf(potentialTokenId) == _owner &&
                creationTime[potentialTokenId] > 0);
    }

    // Get type number for a given token address and ID.
    // - For non-supported tokens, returns 0.
    // - For CS1 (Unicorn), returns 1.
    // - For CS2, returns 2 for Honeybadger, 3 for Llama, 4 for Panda, 5 for Doge.
    function getTypeNum(address _tokenAddress, uint256 _tokenId)
    public view
    returns (uint256)
    {
        if (_tokenAddress == CS1Address) {
            ERC721ExistsI CS1 = ERC721ExistsI(CS1Address);
            require(CS1.exists(_tokenId), "Token ID needs to exist.");
            return 1;
        }
        else if (_tokenAddress == CS2Address) {
            CS2PropertiesI CS2 = CS2PropertiesI(CS2Address);
            return 2 + uint256(CS2.getType(_tokenId));
        }
        return 0;
    }

    // Get color number for a given token address and ID.
    // returns 0 for black, 1 for green, 2 for blue, 3 for yellow, 4 for red.
    function getColorNum(address _tokenAddress, uint256 _tokenId)
    public view
    returns (uint256)
    {
        if (_tokenAddress == CS1Address) {
            CS1ColorsI CS1 = CS1ColorsI(CS1ColorsAddress);
            return uint256(CS1.getColor(_tokenId));
        }
        else if (_tokenAddress == CS2Address) {
            CS2PropertiesI CS2 = CS2PropertiesI(CS2Address);
            return uint256(CS2.getColor(_tokenId));
        }
        return 0;
    }

    // Get points for a given achievement code.
    function getPointsByCode(uint256 _achievementCode)
    public view
    returns (uint256)
    {
        (AchievementCategory category, uint256 typeNum, uint256 colorNum, uint256 count) = getDetailsFromCode(_achievementCode);
        return getPointsByDetails(category, typeNum, colorNum, count);
    }

    // Get points for a given set of details.
    function getPointsByDetails(AchievementCategory _category, uint256 _typeNum, uint256 _colorNum, uint256 _count)
    public view
    returns (uint256)
    {
        if (_category == AchievementCategory.TOTAL) {
            return _count;
        }
        else if (_category == AchievementCategory.COLOR) {
            return _count * colorWeight[_colorNum];
        }
        else if (_category == AchievementCategory.ALLANIMALS) {
            return _count * 5;
        }
        else if (_category == AchievementCategory.ALLCOLORS) {
            if (_typeNum == 1) {
                return 222;
            }
            else {
                return 200;
            }
        }
        else if (_category == AchievementCategory.ALL) {
            return 1000;
        }
        // This should be unreachable if we handle all possible categories above.
        return 0;
    }

    /*** Block transfers and approvals ***/

    function _beforeTokenTransfer(address from, address to, uint256 tokenId)
        virtual
        override
        internal
    {
        if (from != address(0) && to != address(0)) {
            revert("Transfer of achievements is not possible.");
        }
        super._beforeTokenTransfer(from, to, tokenId);
    }

    function approve(address /*to*/, uint256 /*tokenId*/) public virtual override {
        revert("Transfer of achievements is not possible, so approvals are blocked.");
    }

    function setApprovalForAll(address /*operator*/, bool /*approved*/) public virtual override {
        revert("Transfer of achievements is not possible, so approvals are blocked.");
    }

    /*** Enable reverse ENS registration ***/

    // Call this with the address of the reverse registrar for the respecitve network and the ENS name to register.
    // The reverse registrar can be found as the owner of 'addr.reverse' in the ENS system.
    // See https://docs.ens.domains/ens-deployments for address of ENS deployments, e.g. Etherscan can be used to look up that owner on those.
    // namehash.hash("addr.reverse") == "0x91d1777781884d03a6757a803996e38de2a42967fb37eeaca72729271025a9e2"
    // Ropsten: ens.owner(namehash.hash("addr.reverse")) == "0x6F628b68b30Dc3c17f345c9dbBb1E483c2b7aE5c"
    // Mainnet: ens.owner(namehash.hash("addr.reverse")) == "0x084b1c3C81545d370f3634392De611CaaBFf8148"
    function registerReverseENS(address _reverseRegistrarAddress, string calldata _name)
    external
    onlyTokenAssignmentControl
    {
       require(_reverseRegistrarAddress != address(0), "need a valid reverse registrar");
       ENSReverseRegistrarI(_reverseRegistrarAddress).setName(_name);
    }

    /*** Make sure currency doesn't get stranded in this contract ***/

    // If this contract gets a balance in some ERC20 contract after it's finished, then we can rescue it.
    function rescueToken(IERC20 _foreignToken, address _to)
    external
    onlyTokenAssignmentControl
    {
        _foreignToken.transfer(_to, _foreignToken.balanceOf(address(this)));
    }

    // If this contract gets a balance in some ERC721 contract after it's finished, then we can rescue it.
    function approveNFTrescue(IERC721 _foreignNFT, address _to)
    external
    onlyTokenAssignmentControl
    {
        _foreignNFT.setApprovalForAll(_to, true);
    }

    // Make sure this contract cannot receive ETH.
    receive()
    external payable
    {
        revert("The contract cannot receive ETH payments.");
    }
}
function rescueToken(IERC20 _foreignToken, address _to)
external
onlyTokenAssignmentControl
{
    _foreignToken.transfer(_to, _foreignToken.balanceOf(address(this)));
}

// If this contract gets a balance in some ERC20 contract after it's finished, then we can rescue it.