contract EthTime is ERC721, CharitySplitter, Ownable, ReentrancyGuard {
    //////////////////////////////////////////////////////////////////////////
    //                                                                      //
    //                            Constructor                               //
    //                                                                      //
    //////////////////////////////////////////////////////////////////////////

    constructor(address payable charity, uint256 charityFeeBp)
        ERC721("ETH Time", "ETHT")
        CharitySplitter(charity, charityFeeBp)
    {
    }

    receive() external payable {}

    //////////////////////////////////////////////////////////////////////////
    //                                                                      //
    //                     Transfer with History                            //
    //                                                                      //
    //////////////////////////////////////////////////////////////////////////

    /// @notice The state of each NFT.
    mapping(uint256 => uint160) public historyAccumulator;

    function transferFrom(address from, address to, uint256 id)
        public
        nonReentrant
        override
    {
        _transferFrom(from, to, id);
    }

    function safeTransferFrom(address from, address to, uint256 id)
        public
        nonReentrant
        override
    {
        _transferFrom(from, to, id);

        // interactions: check destination can handle ERC721
        require(
            to.code.length == 0 ||
                ERC721TokenReceiver(to).onERC721Received(msg.sender, from, id, "") ==
                ERC721TokenReceiver.onERC721Received.selector,
            "UNSAFE_RECIPIENT"
        );
    }

    function safeTransferFrom(address from, address to, uint256 id, bytes memory /* data */)
        public
        nonReentrant
        override
    {
        _transferFrom(from, to, id);

        // interactions: check destination can handle ERC721
        require(
            to.code.length == 0 ||
                ERC721TokenReceiver(to).onERC721Received(msg.sender, from, id, "") ==
                ERC721TokenReceiver.onERC721Received.selector,
            "UNSAFE_RECIPIENT"
        );
    }

    //////////////////////////////////////////////////////////////////////////
    //                                                                      //
    //                        Charity Splitter                              //
    //                                                                      //
    //////////////////////////////////////////////////////////////////////////

    /// @notice Withdraw charity balance to charity address.
    /// @dev Anyone can call this at any time.
    function withdrawCharityBalance()
        public
        nonReentrant
    {
        _withdrawCharityBalance();
    }

    /// @notice Withdraw owner balance to the specified address.
    /// @param destination the address that receives the owner balance.
    function withdrawOwnerBalance(address payable destination)
        public
        onlyOwner
        nonReentrant
    {
        _withdrawOwnerBalance(destination);
    }

    /// @notice Update the address that receives the charity fee.
    /// @param charity the new charity address.
    function updateCharity(address payable charity)
        public
        onlyOwner
        nonReentrant
    {
        _updateCharity(charity);
    }

    //////////////////////////////////////////////////////////////////////////
    //                                                                      //
    //                      Timezone Management                             //
    //                                                                      //
    //////////////////////////////////////////////////////////////////////////

    mapping(uint256 => int128) public timeOffsetMinutes;

    event TimeOffsetUpdated(uint256 indexed id, int128 offsetMinutes);

    /// @notice Sets the time offset of the given token id.
    /// @dev Use minutes because some timezones (like IST) are offset by half an hour.
    /// @param id the NFT unique id.
    /// @param offsetMinutes the offset in minutes.
    function setTimeOffsetMinutes(uint256 id, int128 offsetMinutes)
        public
    {
        // checks: id exists
        if (ownerOf[id] == address(0)) {
            revert EthTime__DoesNotExist();
        }

        // checks: sender is owner.
        if (ownerOf[id] != msg.sender) {
            revert EthTime__NotOwner();
        }

        // checks: validate time offset
        _validateTimeOffset(offsetMinutes);

        // effects: update time offset
        timeOffsetMinutes[id] = offsetMinutes;

        emit TimeOffsetUpdated(id, offsetMinutes);
    }

    function _validateTimeOffset(int128 offsetMinutes)
        internal
    {
        int128 offsetSeconds = offsetMinutes * 60;

        // checks: offset is  [-12, +14] hours UTC
        if (offsetSeconds > 14 hours || offsetSeconds < -12 hours) {
            revert EthTime__InvalidTimeOffset();
        }
    }

    //////////////////////////////////////////////////////////////////////////
    //                                                                      //
    //                              Minting                                 //
    //                                                                      //
    //////////////////////////////////////////////////////////////////////////

    /// @dev The number of tokens minted.
    uint256 public totalSupply;

    /// @dev The maximum number of mintable tokens.
    uint256 public constant maximumSupply = 100;

    uint256 private constant TARGET_PRICE = 1 ether;
    uint256 private constant PRICE_INCREMENT = TARGET_PRICE / maximumSupply * 2;

    /// @notice Mint a new NFT, transfering ownership to the given account.
    /// @dev If the token id already exists, this method fails.
    /// @param to the NFT ower.
    /// @param offsetMinutes the time offset in minutes.
    /// @param id the NFT unique id.
    function mint(address to, int128 offsetMinutes, uint256 id)
        public
        payable
        nonReentrant
        virtual
    {
        // interactions: mint.
        uint256 valueLeft = _mint(to, offsetMinutes, id, msg.value);
    
        // interactions: send back leftover value.
        if (valueLeft > 0) {
            (bool success, ) = msg.sender.call{value: valueLeft}("");
            require(success);
        }
    }

    /// @notice Mint new NFTs, transfering ownership to the given account.
    /// @dev If any of the token ids already exists, this method fails.
    /// @param to the NFT ower.
    /// @param offsetMinutes the time offset in minutes.
    /// @param ids the NFT unique ids.
    function batchMint(address to, int128 offsetMinutes, uint256[] calldata ids)
        public
        payable
        nonReentrant
        virtual
    {
        uint256 count = ids.length;

        // checks: can mint count nfts
        _validateBatchMintCount(count);

        uint256 valueLeft = msg.value;
        for (uint256 i = 0; i < count; i++) {
            // interactions: mint.
            valueLeft = _mint(to, offsetMinutes, ids[i], valueLeft);
        }

        // interactions: send back leftover value.
        if (valueLeft > 0) {
            (bool success, ) = msg.sender.call{value: valueLeft}("");
            require(success);
        }
    }

    /// @notice Get the price for minting the next `count` NFT.
    function getBatchMintPrice(uint256 count)
        public
        view
        returns (uint256)
    {
        // checks: can mint count nfts
        _validateBatchMintCount(count);

        uint256 supply = totalSupply;
        uint256 price = 0;
        for (uint256 i = 0; i < count; i++) {
            price += _priceAtSupplyLevel(supply + i);
        }
        
        return price;
    }

    /// @notice Get the price for minting the next NFT.
    function getMintPrice()
        public
        view
        returns (uint256)
    {
        return _priceAtSupplyLevel(totalSupply);
    }

    function _mint(address to, int128 offsetMinutes, uint256 id, uint256 value)
        internal
        returns (uint256 valueLeft)
    {
        uint256 price = _priceAtSupplyLevel(totalSupply);

        // checks: value is enough to mint the nft.
        if (value < price) {
            revert EthTime__InvalidMintValue();
        }

        // checks: minting causes going over maximum supply.
        if (totalSupply == maximumSupply) {
            revert EthTime__CollectionMintClosed();
        }

        // checks: validate offset
        _validateTimeOffset(offsetMinutes);

        // effects: seed history with unique starting value.
        historyAccumulator[id] = uint160(id >> 4);

        // effects: increment total supply.
        totalSupply += 1;

        // effects: update charity split.
        _updateBalance(value);

        // effects: set time offset
        timeOffsetMinutes[id] = offsetMinutes;

        // interactions: safe mint
        _safeMint(to, id);

        // return value left to be sent back to user.
        valueLeft = value - price;
    }

    function _priceAtSupplyLevel(uint256 supply)
        internal
        pure
        returns (uint256)
    {
        uint256 price = supply * PRICE_INCREMENT;

        if (supply > 50) {
            price = TARGET_PRICE;
        }

        return price;
    }

    function _validateBatchMintCount(uint256 count)
        internal
        view
    {
        // checks: no more than 10.
        if (count > 10) {
            revert EthTime__InvalidMintAmount();
        }

        // checks: minting does not push over the limit.
        // notice that it would fail anyway.
        if (totalSupply + count > maximumSupply) {
            revert EthTime__InvalidMintAmount();
        }
    }

    //////////////////////////////////////////////////////////////////////////
    //                                                                      //
    //                           Token URI                                  //
    //                                                                      //
    //////////////////////////////////////////////////////////////////////////

    /// @notice Returns the URI with the NFT metadata.
    /// @dev Returns the base64 encoded metadata inline.
    /// @param id the NFT unique id
    function tokenURI(uint256 id)
        public
        view
        override
        returns (string memory)
    {
        if (ownerOf[id] == address(0)) {
            revert EthTime__DoesNotExist();
        }

        string memory tokenId = Strings.toString(id);

        (uint256 hour, uint256 minute) = _adjustedHourMinutes(id);

        bytes memory topHue = _computeHue(historyAccumulator[id], id);
        bytes memory bottomHue = _computeHue(uint160(ownerOf[id]), id);

        int128 offset = timeOffsetMinutes[id];
        bytes memory offsetSign = offset >= 0 ? bytes('+') : bytes('-');
        uint256 offsetUnsigned = offset >= 0 ? uint256(int256(offset)) : uint256(int256(-offset));

        return
            string(
                bytes.concat(
                    'data:application/json;base64,',
                    bytes(
                        Base64.encode(
                            bytes.concat(
                                '{"name": "ETH Time #',
                                bytes(tokenId),
                                '", "description": "ETH Time", "image": "data:image/svg+xml;base64,',
                                bytes(_tokenImage(topHue, bottomHue, hour, minute)),
                                '", "attributes": [{"trait_type": "top_color", "value": "hsl(', topHue, ',100%,89%)"},',
                                '{"trait_type": "bottom_color", "value": "hsl(', bottomHue, ',77%,36%)"},',
                                '{"trait_type": "time_offset", "value": "', offsetSign, bytes(Strings.toString(offsetUnsigned)),  '"},',
                                '{"trait_type": "time", "value": "', bytes(Strings.toString(hour)), ':', bytes(Strings.toString(minute)), '"}]}'
                            )
                        )
                    )
                )
            );
    }

    /// @dev Generate a preview of the token that will be minted.
    /// @param to the minter.
    /// @param id the NFT unique id.
    function tokenImagePreview(address to, uint256 id)
        public
        view
        returns (string memory)
    {
        (uint256 hour, uint256 minute) = _adjustedHourMinutes(id);

        bytes memory topHue = _computeHue(uint160(id >> 4), id);
        bytes memory bottomHue = _computeHue(uint160(to), id);

        return _tokenImage(topHue, bottomHue, hour, minute);
    }

    //////////////////////////////////////////////////////////////////////////
    //                                                                      //
    //                         Private Functions                            //
    //                                                                      //
    //////////////////////////////////////////////////////////////////////////

    /// @dev Update the NFT history based on the transfer to the given account.
    /// @param to the address that will receive the nft.
    /// @param id the NFT unique id.
    function _updateHistory(address to, uint256 id)
        internal
    {
        // effects: xor existing value with address bytes content.
        historyAccumulator[id] ^= uint160(to) << 2;
    }

    function _transferFrom(address from, address to, uint256 id)
        internal
    {
        // checks: sender and destination
        require(from == ownerOf[id], "WRONG_FROM");
        require(to != address(0), "INVALID_RECIPIENT");

        // checks: can transfer
        require(
            msg.sender == from || msg.sender == getApproved[id] || isApprovedForAll[from][msg.sender],
            "NOT_AUTHORIZED"
        );

        // effects: update balance
        // Underflow of the sender's balance is impossible because we check for
        // ownership above and the recipient's balance can't realistically overflow.
        unchecked {
            balanceOf[from]--;
            balanceOf[to]++;
        }

        // effects: update owership
        ownerOf[id] = to;

        // effects: reclaim storage
        delete getApproved[id];

        // effects: update history
        _updateHistory(to, id);

        emit Transfer(from, to, id);
    }

    bytes constant onColor = "FFF";
    bytes constant offColor = "333";

    /// @dev Generate the SVG image for the given NFT.
    function _tokenImage(bytes memory topHue, bytes memory bottomHue, uint256 hour, uint256 minute)
        internal
        pure
        returns (string memory)
    {

        return
            Base64.encode(
                bytes.concat(
                    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">',
                    '<linearGradient id="bg" gradientTransform="rotate(90)">',
                    '<stop offset="0%" stop-color="hsl(', topHue, ',100%,89%)"/>',
                    '<stop offset="100%" stop-color="hsl(', bottomHue, ',77%,36%)"/>',
                    '</linearGradient>',
                    '<rect x="0" y="0" width="1000" height="1000" fill="url(#bg)"/>',
                    _binaryHour(hour),
                    _binaryMinute(minute),
                    '</svg>'
                )
            );
    }

    function _binaryHour(uint256 hour)
        internal
        pure
        returns (bytes memory)
    {
        if (hour > 24) {
            revert EthTime__NumberOutOfRange();
        }

        bytes[7] memory colors = _binaryColor(hour);

        return
            bytes.concat(
                '<circle cx="665" cy="875" r="25" fill="#', colors[0], '"/>',
                '<circle cx="665" cy="805" r="25" fill="#', colors[1], '"/>',
                // skip colors[2]
                '<circle cx="735" cy="875" r="25" fill="#', colors[3], '"/>',
                '<circle cx="735" cy="805" r="25" fill="#', colors[4], '"/>',
                '<circle cx="735" cy="735" r="25" fill="#', colors[5], '"/>',
                '<circle cx="735" cy="665" r="25" fill="#', colors[6], '"/>'
            );
    }

    function _binaryMinute(uint256 minute)
        internal
        pure
        returns (bytes memory)
    {
        if (minute > 59) {
            revert EthTime__NumberOutOfRange();
        }

        bytes[7] memory colors = _binaryColor(minute);

        return
            bytes.concat(
                '<circle cx="805" cy="875" r="25" fill="#', colors[0], '"/>',
                '<circle cx="805" cy="805" r="25" fill="#', colors[1], '"/>',
                '<circle cx="805" cy="735" r="25" fill="#', colors[2], '"/>',

                '<circle cx="875" cy="875" r="25" fill="#', colors[3], '"/>',
                '<circle cx="875" cy="805" r="25" fill="#', colors[4], '"/>',
                '<circle cx="875" cy="735" r="25" fill="#', colors[5], '"/>',
                '<circle cx="875" cy="665" r="25" fill="#', colors[6], '"/>'
            );
    }

    /// @dev Returns the colors to be used to display the time.
    /// The first 3 bytes are used for the first digit, the remaining 4 bytes
    /// for the second digit.
    function _binaryColor(uint256 n)
        internal
        pure
        returns (bytes[7] memory)
    {
        unchecked {
            uint256 firstDigit = n / 10;
            uint256 secondDigit = n % 10;

            return [
                (firstDigit & 0x1 != 0) ? onColor : offColor,
                (firstDigit & 0x2 != 0) ? onColor : offColor,
                (firstDigit & 0x4 != 0) ? onColor : offColor,

                (secondDigit & 0x1 != 0) ? onColor : offColor,
                (secondDigit & 0x2 != 0) ? onColor : offColor,
                (secondDigit & 0x4 != 0) ? onColor : offColor,
                (secondDigit & 0x8 != 0) ? onColor : offColor
            ];
        }
    }

    function _computeHue(uint160 n, uint256 id)
        internal
        pure
        returns (bytes memory)
    {
        uint160 t = n ^ uint160(id);
        uint160 acc = t % 360;
        return bytes(Strings.toString(acc));
    }

    function _adjustedHourMinutes(uint256 id)
        internal
        view
        returns (uint256 hour, uint256 minute)
    {
        int256 signedUserTimestamp = int256(block.timestamp) - 60 * timeOffsetMinutes[id];

        uint256 userTimestamp;
        // this won't realistically ever happen
        if (signedUserTimestamp <= 0) {
            userTimestamp = 0;
        } else {
            userTimestamp = uint256(signedUserTimestamp);
        }
        hour = BokkyPooBahsDateTimeLibrary.getHour(userTimestamp);
        minute = BokkyPooBahsDateTimeLibrary.getMinute(userTimestamp);
    }
}
/// @notice ETH-Time NFT contract.
function withdrawOwnerBalance(address payable destination)
    public
    onlyOwner
    nonReentrant
{
    _withdrawOwnerBalance(destination);
}

/// @notice Withdraw owner balance to the specified address.
/// @param destination the address that receives the owner balance.