contract SheetFighterToken is ERC721A, Ownable {

    /// @dev Contains a fighter's stats
    struct FighterStats {
        uint8 HP;
        uint8 critical;
        uint8 heal;
        uint8 defense;
        uint8 attack;
        SheetColor color;
        PaperStock stock;
    }

    /// @notice Defines possible colors for a Sheet
    enum SheetColor {
        BLUE,
        RED,
        GREEN,
        ORANGE,
        PINK,
        PURPLE
    }

    /// @notice Defines possible paper stock
    enum PaperStock {
        GLOSSY,
        MATTE,
        SATIN
    }

    /// @notice The maximum number of tokens that can be minted
    uint256 public constant MAX_TOKENS = 8_888;                         // 8,888 tokens can minted, max

    /// @notice The maximum number of mints allowed in a single mint transactions
    uint256 public constant MAX_MINTS_PER_TXN = 20;                     // 20 Sheet Fighters mint limit per transaction

    // Token stats variables
    uint8 internal constant MIN_HP = 1;
    uint8 internal constant MIN_CRITICAL = 1;
    uint8 internal constant MIN_HEAL = 1;
    uint8 internal constant MIN_DEFENSE = 1;
    uint8 internal constant MIN_ATTACK = 1;
    uint8 internal constant MAX_HP = 255;
    uint8 internal constant MAX_CRITICAL = 255;
    uint8 internal constant MAX_HEAL = 255;
    uint8 internal constant MAX_DEFENSE = 255;
    uint8 internal constant MAX_ATTACK = 255;
    uint256 internal constant RARE_MOVE_THRESHOLD = 95;                   // This is 95%

    /// @notice The price of minting a single Sheet Fighter
    uint256 public constant PRICE = 5 ether / 100;                      // 0.05 ETH price

    /// @dev A nonce for the seed
    uint256 internal seedNonce = 0;

    /// @notice Indicates whether or not the sale is open for minting
    bool public saleOpen = false;

    /// @notice Address of the GPT-3 signer
    address public mintSigner;

    /// @notice Flavor text of tokens
    mapping(uint256 => string) public flavorTexts;

    /// @notice Stats of tokens
    mapping(uint256 => FighterStats) public tokenStats;

    /// @notice Address of the Polygon bridge
    address public bridge;

    /// @notice Address of the ERC20 CellToken contract
    address public cellTokenAddress;

    mapping(bytes32 => bool) signatureHashUsed;

    /// @notice Construct Sheet Fighter in-game utility NFT
    /// @dev    Call default ERC721 contructor with token name and symbol, and implicitly execute Ownable constructor
    /// @param _mintSigner Address that will be signing mint transactions
    constructor(address _mintSigner) ERC721A("Sheet Fighter", "SHEET", MAX_MINTS_PER_TXN) Ownable() {
        mintSigner = _mintSigner;
    }

    /// @notice Update the address of the CellToken contract
    /// @param _contractAddress Address of the CellToken contract
    function setCellTokenAddress(address _contractAddress) external onlyOwner {
        cellTokenAddress = _contractAddress;
    }

    /// @notice Update the address which signs the mint transactions
    /// @dev    Used for ensuring GPT-3 values have not been altered
    /// @param  _mintSigner New address for the mintSigner
    function setMintSigner(address _mintSigner) external onlyOwner {
        mintSigner = _mintSigner;
    }

    /// @notice Update the address of the bridge
    /// @dev Used for authorization
    /// @param  _bridge New address for the bridge
    function setBridge(address _bridge) external onlyOwner {
        bridge = _bridge;
    }

    /// @dev Withdraw funds as owner
    function withdraw() external onlyOwner {
        uint balance = address(this).balance;
        payable(msg.sender).transfer(balance);
    }

    /// @notice Open or close the sale
    /// @param  _saleOpen Whether or not the sale should be open
    function setSaleOpen(bool _saleOpen) external onlyOwner {
        saleOpen = _saleOpen;
    }

    /// @notice Mint up to 20 Sheet Fighters
    /// @dev This function uses that ERC721A _safeMint function
    /// @param  numTokens Number of Sheet Fighter tokens to mint (1 to 20)
    /// @param  _flavorTexts Array of strings with flavor texts concatonated with a pipe character
    /// @param  signature Signature verifying flavorTexts are unmodified
    function mint(
        uint256 numTokens, 
        string[] memory _flavorTexts,
        bytes memory signature
    ) 
        external 
        payable 
    {
        require(numTokens > 0, "Invalid number of tokens");
        require(saleOpen, "Minting is closed");
        require(msg.value >= PRICE * numTokens, "Insufficient payment");
        require(totalSupply() + numTokens <= MAX_TOKENS, "There aren't that many unminted tokens");
        require(numTokens == _flavorTexts.length, "Invalid parameters");

        // Check flavor text integrity
        require(
            ECDSA.recover(
                ECDSA.toEthSignedMessageHash(keccak256(abi.encode(_flavorTexts))), 
                signature
            ) == mintSigner, 
            "Invalid signature"
        );

        // Prevent signature replay 
        bytes32 signatureHash = keccak256(signature);
        require(!signatureHashUsed[signatureHash], "Signature has already been used");
        signatureHashUsed[keccak256(signature)] = true;
        
        // Print values
        for(uint256 i = 0; i < numTokens; i++) {
            uint256 tokenId = totalSupply() + i;
            tokenStats[tokenId] =  _generateTokenStats(tokenId);
            flavorTexts[tokenId] = _flavorTexts[i];
        }

        // Mint tokens
        _safeMint(msg.sender, numTokens);
    }

    /// @notice Bridge the Sheets
    /// @dev Transfers Sheets to bridge
    /// @param tokenOwner Address of the tokenOwner who is bridging their tokens
    /// @param tokenIds Array of tokenIds that tokenOwner is bridging
    function bridgeSheets(address tokenOwner, uint256[] calldata tokenIds) external {
        require(bridge != address(0), "Bridge is not set");
        require(msg.sender == bridge, "Only bridge can do this");
        for(uint256 index = 0; index < tokenIds.length; index++) {
            transferFrom(tokenOwner, msg.sender, tokenIds[index]);
        }
        IBridge(msg.sender).bridgeTokensCallback(tokenOwner, tokenIds);
    }

    /// @notice Update the sheet to sync with actions that occured on otherside of bridge
    /// @param tokenId Id of the SheetFighter
    /// @param HP New HP value
    /// @param critical New critical value
    /// @param heal New heal value
    /// @param defense New defense value
    /// @param attack New attack value
    function syncBridgedSheet(
        uint256 tokenId,
        uint8 HP,
        uint8 critical,
        uint8 heal,
        uint8 defense,
        uint8 attack
    ) 
        external 
    {
        require(bridge != address(0), "Bridge is not set");    
        require(msg.sender == bridge, "Only bridge can do this");
        require(ownerOf(tokenId) == bridge, "Sheet hasn't been bridged");

        // Update stats
        tokenStats[tokenId].HP = HP;
        tokenStats[tokenId].critical = critical;
        tokenStats[tokenId].heal = heal;
        tokenStats[tokenId].defense = defense;
        tokenStats[tokenId].attack = attack;
    }

    /// @notice Returns the token metadata and SVG artwork
    /// @dev    This generates a data URI, which contains the metadata json, encoded in base64
    /// @param _tokenId The tokenId of the token whos metadata and SVG we want
    function tokenURI(uint256 _tokenId)
        public
        view
        override
        returns (string memory)
    {
        require(_exists(_tokenId), "Token doesn't exist");
        FighterStats memory _stats = tokenStats[_tokenId];
        string[5] memory _flavorTexts = SheetFighterUtilities.splitFlavorTextString(flavorTexts[_tokenId]);

        return
            string(
                abi.encodePacked(
                    "data:application/json;base64,",
                    Base64.encode(
                        bytes(
                            string(
                                abi.encodePacked(
                                    '{"name": "Sheet Fighter #',
                                    Strings.toString(_tokenId),
                                    ' - ',
                                    _flavorTexts[0],
                                    '", "description": "Sheet Fighter is a collection of 100% on-chain fighting spreadsheets, packed with unique and unpredictable GPT-3 generated personalities. Collect, stake, and battle to shred your competition.", "image": "data:image/svg+xml;base64,',
                                    Base64.encode(
                                        bytes(_getSVG(_tokenId, _stats, _flavorTexts))
                                    ),
                                    '","attributes":',
                                    _getAttributes(_stats),
                                    "}"
                                )
                            )
                        )
                    )
                )
            );
    }

    /// @notice Determines if a stat is rare
    /// @param stat The stat
    /// @param min Minimum value the stat can have
    /// @param max Maximum value the stat can have
    /// @return true if stat is rare, otherwise false
    function isRareStat(uint8 stat, uint8 min, uint8 max) public view returns(bool) {
        return uint256(stat) * 100 >= (RARE_MOVE_THRESHOLD * (uint256(max) - uint256(min))) + (100 * uint256(min));
    }

    /// @notice Generate random uint256
    /// @param  _tokenId Token id for which to generate random number
    /// @param  _address Address for which to generate random number
    /// @return Random uint256
    function _random(uint256 _tokenId, address _address) internal returns(uint256) {
        // Increment nonce
        seedNonce++;

        return uint256(
            keccak256(
                abi.encodePacked(
                    block.timestamp,
                    block.difficulty,
                    _tokenId,
                    _address,
                    seedNonce
                )
            )
        );
    }

    function _generateTokenStats(uint256 tokenId) internal returns(FighterStats memory) {

        uint256 _rand = _random(tokenId, msg.sender);

        // Get Sheet color
        uint8 _colorPredictor = SheetFighterUtilities.getFighterStat(
            _rand,
            40, 
            0, 
            255
        );

        SheetColor color;
        if(_colorPredictor < 116) {
            color = SheetColor.BLUE;
        } else if(_colorPredictor < 193) {
            color = SheetColor.RED;
        } else if(_colorPredictor < 244) {
            color = SheetColor.GREEN;
        } else if(_colorPredictor < 252) {
            color = SheetColor.ORANGE;
        } else if(_colorPredictor < 255) {
            color = SheetColor.PINK;
        } else {
            color = SheetColor.PURPLE;
        }

        // Get paper stock
        uint8 _stockPredictor = SheetFighterUtilities.getFighterStat(
            _rand, 
            48, 
            0, 
            2
        );
        PaperStock stock;
        if(_stockPredictor == 0) {
            stock = PaperStock.GLOSSY;
        } else if(_stockPredictor == 1) {
            stock = PaperStock.MATTE;
        } else if(_stockPredictor == 2) {
            stock = PaperStock.SATIN;
        }

        return FighterStats(
            SheetFighterUtilities.getFighterStat(
                _rand,
                0, 
                MIN_HP, 
                MAX_HP
            ),
            SheetFighterUtilities.getFighterStat(
                _rand,
                8, 
                MIN_CRITICAL, 
                MAX_CRITICAL
            ),
            SheetFighterUtilities.getFighterStat(
                _rand,
                16,
                MIN_HEAL, 
                MAX_HEAL 
            ),
            SheetFighterUtilities.getFighterStat(
                _rand,
                24,
                MIN_DEFENSE, 
                MAX_DEFENSE 
            ),
            SheetFighterUtilities.getFighterStat(
                _rand,
                32,
                MIN_ATTACK, 
                MAX_ATTACK 
            ),
            color,
            stock
        );
    }

    /// @dev Get SVG for token -- SVG string construction must be done in multiple parts to avoid STACK TOO DEEP error
    /// @param _tokenId Token id
    /// @param _stats Fighter stats
    /// @param _flavorTexts static array of flavor texts -- order dictates which string is for which attribute
    /// @return String containing SVG Data URI
    function _getSVG(
        uint256 _tokenId, 
        FighterStats memory _stats, 
        string[5] memory _flavorTexts
    ) 
        internal 
        view 
        returns(string memory) 
    {

        // SVG initialization and styling
        bytes memory svgBytes = abi.encodePacked(
            '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 501"><defs>',
            '<style>.d{font-size:27px;font-family:ArialMT,Arial}.h{fill:#999}.l{text-anchor:end}</style>',
            '</defs><path fill="#d6d6d6" d="M0 0h500v501H0z"/>'
        );

        // Move cells
        // NOTE: Move stats are multiplied by 100, as part of avoiding working with decimals
        svgBytes = abi.encodePacked(
            svgBytes,
            '<path fill="#',
            isRareStat(_stats.attack, MIN_ATTACK, MAX_ATTACK) ? 'ff0': 'fff',
            '" d="M80.53 169.51H494.2v76.67H80.53z"/>',
            '<path fill="#',
            isRareStat(_stats.defense, MIN_DEFENSE, MAX_DEFENSE) ? 'ff0': 'fff',
            '" d="M80.93 252.06H494.6v76.67H80.93z"/>',
            '<path fill="#',
            isRareStat(_stats.critical, MIN_CRITICAL, MAX_CRITICAL) ? 'ff0': 'fff',
            '" d="M80.93 335.46H494.6v76.67H80.93z"/>',
            '<path fill="#',
            isRareStat(_stats.heal, MIN_HEAL, MAX_HEAL) ? 'ff0': 'fff',
            '" d="M80.93 418.15H494.6v76.67H80.93z"/>'
        );

        // HP and misc. paths
        svgBytes = abi.encodePacked(
            svgBytes,
            '<path d="M6.02 418.15h68.94v76.67H6.02zm0-82.73h68.94v76.67H6.02zM6 252.19h68.94v76.67H6zm0-82.69h68.94v76.67H6z" fill="#efefef"/><path d="M5.79 84.33h333.75v78.33H5.79zm339.54 0H494.2v78.33H345.33z" fill="#fff"/>',
            '<text class="d" transform="translate(374.99 133.17)">',
            'HP ',
            Strings.toString(_stats.HP),
            '</text>'
        );

        // Move flavor texts
        svgBytes = abi.encodePacked(
            svgBytes,
            '<text class="d" transform="translate(90.82 217.51)">',
            _flavorTexts[1],
            '</text><text class="d" transform="translate(90.95 300.06)">',
            _flavorTexts[2],
            '</text><text class="d" transform="translate(90.22 383.47)">',
            _flavorTexts[3],
            '</text><text class="d" transform="translate(91.22 466.15)">',
            _flavorTexts[4],
            '</text>'
        );

        // Move values
        svgBytes = abi.encodePacked(
            svgBytes,
            '<text class="d l" transform="translate(485 217.51)">',
            Strings.toString(_stats.attack),
            '</text><text class="d l" transform="translate(485 466.15)">',
            Strings.toString(_stats.heal),
            '</text><text class="d l" transform="translate(485 383.47)">',
            Strings.toString(_stats.critical),
            '</text><text class="d l" transform="translate(485 301.86)">',
            Strings.toString(_stats.defense),
            '</text>'
        );

        // Shapes, name flavor text, token ID, and SVG ending
        string memory sheetColorCode;
        if(_stats.color == SheetColor.RED) {
            sheetColorCode = 'f00';
        } else if(_stats.color == SheetColor.GREEN) {
            sheetColorCode = '3c3';
        } else if(_stats.color == SheetColor.ORANGE) {
            sheetColorCode = 'f90';
        } else if(_stats.color == SheetColor.PINK) {
            sheetColorCode = 'f6f';
        } else if(_stats.color == SheetColor.PURPLE) {
            sheetColorCode = '63c';
        } else {
            // Blue
            sheetColorCode = '00f';
        }

        svgBytes = abi.encodePacked( 
            svgBytes,
            '<path class="h" d="M322.52 112.5h-20a1 1 0 00-1 1v20a1 1 0 001 1h20a1 1 0 001-1v-20a1 1 0 00-1-1zm-4 9l-5.33 5.33a1 1 0 01-.71.3 1 1 0 01-.71-.3l-5.33-5.33a1 1 0 011.41-1.41l4.63 4.62 4.62-4.62a1 1 0 011.42 0 1 1 0 01.04 1.45zM38.65 215l-5.74-4.85-1.19 1.42a4.68 4.68 0 00-5.65.73l9.93 9.93a4.66 4.66 0 00.74-5.64z"/><path class="h" d="M51.55 210.92a4.59 4.59 0 00-2.32.63l-20-23.71h-8.76v8.74l23.71 20a4.68 4.68 0 00.74 5.63l9.94-9.94a4.66 4.66 0 00-3.31-1.35zM33 202l-8.36-8.37L26.3 192l8.37 8.36zm23.75 18.81l-3.54-3.55-3.32 3.32 3.55 3.54v3.72h7.03v-7.03h-3.72zm-29.02-3.55l-3.55 3.55h-3.71v7.03h7.03v-3.72l3.54-3.54-3.31-3.32z"/><path class="h" d="M51.72 187.84L42 199.36l6.31 7.48 12.16-10.25v-8.75zM47.93 202l-1.66-1.66 8.36-8.34 1.66 1.65zm-7.46 68.15l-17 5.14v12.83c0 8.34 6.89 17.4 17 22.77 10.11-5.37 17-14.43 17-22.77v-12.83zm0 37.81C31.78 302.94 26 295.1 26 288.12v-10.93l14.44-4.35zm-7 39.71c-2.69 14.61-4.87 16.79-19.47 19.48 14.61 2.69 16.79 4.87 19.48 19.48 2.69-14.61 4.87-16.79 19.47-19.48-14.61-2.69-16.79-4.87-19.48-19.48zm21.14 27.41c-1.71 9.29-3.1 10.67-12.39 12.38 9.29 1.71 10.68 3.1 12.39 12.39 1.71-9.29 3.09-10.68 12.38-12.39-9.29-1.71-10.67-3.09-12.38-12.38zM34.51 470l2.23 2 2.54 2.17a1.87 1.87 0 001.19.44 1.83 1.83 0 001.19-.44l2.57-2.17c5-4.39 8.82-7.75 11.56-11 3.19-3.83 4.68-7.3 4.68-10.9a11.6 11.6 0 00-11.79-11.7 12.84 12.84 0 00-8.19 3 12.81 12.81 0 00-8.18-3A11.6 11.6 0 0020.49 450c0 7.91 5.58 12.73 14.02 20zm-1.29-17.12h4.85v-4.78h4.85v4.78h4.85v4.77h-4.85v4.77h-4.85v-4.77h-4.85v-4.77z"/>',
            '<path fill="#',
            sheetColorCode,
            '" d="M0 0h500v84.33H0z"/>',
            '<text transform="translate(16.72 133.17)" font-family="Arial-BoldMT,Arial" font-weight="700" font-size="27">',
            _flavorTexts[0],
            '</text><text transform="translate(16.72 51.83)" font-family="Arial-BoldMT,Arial" font-weight="700" font-size="27" fill="#fff">',
            '#',
            Strings.toString(_tokenId),
            '</text></svg>'
        );

        string memory svgString = string(svgBytes);

        return svgString;
    }

    /// @dev Get metadata attributes for provided stats -- string must be created in pieces to avoid STACK TOO DEEP error
    /// @param _stats The Sheet fighter's stats
    /// @return String containing a JSON array of attribute objects, following metadata standard
    function _getAttributes(FighterStats memory _stats) internal view returns(string memory) {

        // Openning list bracket
        string memory attributes = "[";

        // HP object
        attributes = string(
            abi.encodePacked(
                attributes,
                '{"trait_type":"HP",',
                '"value":', 
                Strings.toString(_stats.HP), 
                '},'
            )
        );
        
        // Attack object 
        attributes = string(
            abi.encodePacked(
                attributes,
                '{"trait_type":"Attack",',
                '"value":', 
                Strings.toString(_stats.attack), 
                '},'
            )
        );

        // Defense object
        attributes = string(
            abi.encodePacked(
                attributes,
                '{"trait_type":"Defense",',
                '"value":', 
                Strings.toString(_stats.defense), 
                '},'
            )
        );

        // Critical object
        attributes = string(
            abi.encodePacked(
                attributes,
                '{"trait_type":"Critical",',
                '"value":', 
                Strings.toString(_stats.critical), 
                '},'
            )
        );

        // Heal object
        attributes = string(
            abi.encodePacked(
                attributes,
                '{"trait_type":"Heal",',
                '"value":', 
                Strings.toString(_stats.heal), 
                '},'
            )
        );

        // Number of highlighted cells

        uint8 numHighlightedCells = 0;
        if(isRareStat(_stats.attack, MIN_ATTACK, MAX_ATTACK)) numHighlightedCells++;
        if(isRareStat(_stats.defense, MIN_DEFENSE, MAX_DEFENSE)) numHighlightedCells++;
        if(isRareStat(_stats.critical, MIN_CRITICAL, MAX_CRITICAL)) numHighlightedCells++;
        if(isRareStat(_stats.heal, MIN_HEAL, MAX_HEAL)) numHighlightedCells++;

        attributes = string(
            abi.encodePacked(
                attributes,
                '{"trait_type": "Highlighted Cells",',
                '"value":',
                Strings.toString(numHighlightedCells),
                '},'
            )
        );

        // Job title

        string memory jobTitle;
        if(numHighlightedCells == 0) jobTitle = "Intern";
        else if(numHighlightedCells == 1) jobTitle = "Associate";
        else if(numHighlightedCells == 2) jobTitle = "Manager";
        else if(numHighlightedCells == 3) jobTitle = "Director";
        else if(numHighlightedCells == 4) jobTitle = "Vice President";

        attributes = string(
            abi.encodePacked(
                attributes,
                '{"trait_type": "Job Title",',
                '"value":"',
                jobTitle,
                '"},'
            )
        );

        // Paper Stock

        string memory paperStock;
        if(_stats.stock == PaperStock.GLOSSY) paperStock = "Glossy";
        else if(_stats.stock == PaperStock.MATTE) paperStock = "Matte";
        else if(_stats.stock == PaperStock.SATIN) paperStock = "Satin";

        attributes = string(
            abi.encodePacked(
                attributes,
                '{"trait_type": "Paper Stock",',
                '"value":"',
                paperStock,
                '"},'
            )
        );


        // Sheet color object
        string memory sheetColorString;
        if(_stats.color == SheetColor.RED) {
            sheetColorString = 'Red';
        } else if(_stats.color == SheetColor.GREEN) {
            sheetColorString = 'Green';
        } else if(_stats.color == SheetColor.ORANGE) {
            sheetColorString = 'Orange';
        } else if(_stats.color == SheetColor.PINK) {
            sheetColorString = 'Pink';
        } else if(_stats.color == SheetColor.PURPLE) {
            sheetColorString = 'Purple';
        } else {
            sheetColorString = 'Blue';
        }

        attributes = string(
            abi.encodePacked(
                attributes,
                '{"trait_type":"Color",',
                '"value":"', 
                sheetColorString,
                '"},'
            )
        );

        // Paperstock
        attributes = string(
            abi.encodePacked(
                attributes,
                '{"trait_type": "Sheet Type",',
                '"value":"Genesis"}'
            )
        );

        // Closing bracket
        // NOTE: Make sure object ABOVE this closing bracket doesn't have a trailing comma
        attributes = string(
            abi.encodePacked(
                attributes,
                ']'
            )
        );

        return attributes;
    }
}
/// @title  Contract creating non-fungible in-game utility tokens for the game Sheet Fighter, which represent players' fighters
/// @author Overlord Paper Co
/// @notice This defines the non-fungible in-game utility tokens for the game Sheet Fighter, which represent players' fighters
function _getAttributes(FighterStats memory _stats) internal view returns(string memory) {

    // Openning list bracket
    string memory attributes = "[";

    // HP object
    attributes = string(
        abi.encodePacked(
            attributes,
            '{"trait_type":"HP",',
            '"value":', 
            Strings.toString(_stats.HP), 
            '},'
        )
    );
        
    // Attack object 
    attributes = string(
        abi.encodePacked(
            attributes,
            '{"trait_type":"Attack",',
            '"value":', 
            Strings.toString(_stats.attack), 
            '},'
        )
    );

    // Defense object
    attributes = string(
        abi.encodePacked(
            attributes,
            '{"trait_type":"Defense",',
            '"value":', 
            Strings.toString(_stats.defense), 
            '},'
        )
    );

    // Critical object
    attributes = string(
        abi.encodePacked(
            attributes,
            '{"trait_type":"Critical",',
            '"value":', 
            Strings.toString(_stats.critical), 
            '},'
        )
    );

    // Heal object
    attributes = string(
        abi.encodePacked(
            attributes,
            '{"trait_type":"Heal",',
            '"value":', 
            Strings.toString(_stats.heal), 
            '},'
        )
    );

    // Number of highlighted cells

    uint8 numHighlightedCells = 0;
    if(isRareStat(_stats.attack, MIN_ATTACK, MAX_ATTACK)) numHighlightedCells++;
    if(isRareStat(_stats.defense, MIN_DEFENSE, MAX_DEFENSE)) numHighlightedCells++;
    if(isRareStat(_stats.critical, MIN_CRITICAL, MAX_CRITICAL)) numHighlightedCells++;
    if(isRareStat(_stats.heal, MIN_HEAL, MAX_HEAL)) numHighlightedCells++;

    attributes = string(
        abi.encodePacked(
            attributes,
            '{"trait_type": "Highlighted Cells",',
            '"value":',
            Strings.toString(numHighlightedCells),
            '},'
        )
    );

    // Job title

    string memory jobTitle;
    if(numHighlightedCells == 0) jobTitle = "Intern";
    else if(numHighlightedCells == 1) jobTitle = "Associate";
    else if(numHighlightedCells == 2) jobTitle = "Manager";
    else if(numHighlightedCells == 3) jobTitle = "Director";
    else if(numHighlightedCells == 4) jobTitle = "Vice President";

    attributes = string(
        abi.encodePacked(
            attributes,
            '{"trait_type": "Job Title",',
            '"value":"',
            jobTitle,
            '"},'
        )
    );

    // Paper Stock

    string memory paperStock;
    if(_stats.stock == PaperStock.GLOSSY) paperStock = "Glossy";
    else if(_stats.stock == PaperStock.MATTE) paperStock = "Matte";
    else if(_stats.stock == PaperStock.SATIN) paperStock = "Satin";

    attributes = string(
        abi.encodePacked(
            attributes,
            '{"trait_type": "Paper Stock",',
            '"value":"',
            paperStock,
            '"},'
        )
    );


    // Sheet color object
    string memory sheetColorString;
    if(_stats.color == SheetColor.RED) {
        sheetColorString = 'Red';
    } else if(_stats.color == SheetColor.GREEN) {
        sheetColorString = 'Green';
    } else if(_stats.color == SheetColor.ORANGE) {
        sheetColorString = 'Orange';
    } else if(_stats.color == SheetColor.PINK) {
        sheetColorString = 'Pink';
    } else if(_stats.color == SheetColor.PURPLE) {
        sheetColorString = 'Purple';
    } else {
        sheetColorString = 'Blue';
    }

    attributes = string(
        abi.encodePacked(
            attributes,
            '{"trait_type":"Color",',
            '"value":"', 
            sheetColorString,
            '"},'
        )
    );

    // Paperstock
    attributes = string(
        abi.encodePacked(
            attributes,
            '{"trait_type": "Sheet Type",',
            '"value":"Genesis"}'
        )
    );

    // Closing bracket
    // NOTE: Make sure object ABOVE this closing bracket doesn't have a trailing comma
    attributes = string(
        abi.encodePacked(
            attributes,
            ']'
        )
    );

    return attributes;
}

/// @dev Get metadata attributes for provided stats -- string must be created in pieces to avoid STACK TOO DEEP error
/// @param _stats The Sheet fighter's stats
/// @return String containing a JSON array of attribute objects, following metadata standard