// Contract: StaffTokens
// Target function: function nextPaymentDate(string calldata symbol, address account) external view returns (Date memory)
// SPDX-License-Identifier: MIT

pragma solidity 0.8.17;

// ERC20 methods required by this contract
interface ERC20 {
  function transfer(address, uint256) external returns (bool);
  function balanceOf(address) external view returns (uint256);
}

struct Date {
  uint256 day;
  uint256 month;
  uint256 year;
}

contract StaffTokens {

  uint256 constant private TIME_BETWEEN_PAYMENTS = 30 days;

  mapping(bytes32 => address) private TokenAddress;
  mapping(address => uint256) private Obligation;
  mapping(address => address[]) private TokenSubscribers;
  mapping(bytes32 => uint256) private NextPaymentDue;
  mapping(bytes32 => uint256) private NumPaymentsRemaining;
  mapping(bytes32 => uint256) private FirstPaymentAmount;
  mapping(bytes32 => uint256) private RegularPaymentAmount;
  mapping(address => bool) public isAdmin;

  string[] private TokenSymbols;
  address public owner;

  error AccountAlreadyExists();
  error AddressIsZero();
  error AmountIsZero();
  error InvalidDate();
  error NumPaymentsIsZero();
  error OwnerOnly();
  error OwnerOrAdminOnly();
  error TokenAlreadyAdded();
  error UnrecognisedToken();

  event AdminStatusUpdated(address indexed account, bool isAdmin);
  event BeneficiaryAdded(address indexed token, address indexed account, uint256 award, uint256 numPayments, uint256 startTimestamp);
  event BeneficiaryRemoved(address indexed token, address indexed account, uint256 awardRemainder, uint256 numPaymentsOutstanding);

  modifier ownerOnly {
    if (msg.sender != owner) revert OwnerOnly();
    _;
  }

  modifier ownerOrAdminOnly {
    if (msg.sender != owner && !isAdmin[msg.sender]) revert OwnerOrAdminOnly();
    _;
  }

  constructor() {
    owner = msg.sender;
  }

  /// @notice Add a new token
  /// @param token Ethereum address of the token contract
  /// @param symbol 3 or 4 letter token 'ticker'
  /// @dev Required before any payments of this token can be made.
  function owner_registerToken(address token, string calldata symbol) external ownerOnly {
    bytes32 symbolHash = keccak256(abi.encode(symbol));
    if (TokenAddress[symbolHash] != address(0)) revert TokenAlreadyAdded();
    TokenAddress[symbolHash] = token;
    TokenSymbols.push(symbol);
  }

  /// @notice Remove an existing token
  /// @param symbol 3 or 4 letter token 'ticker'
  /// @dev Prevents further payments for this token. Does not remove existing beneficiaries. Does not withdraw any token funds.
  function owner_revokeToken(string calldata symbol) external ownerOnly {
    bytes32 symbolHash = keccak256(abi.encode(symbol));

    if (TokenAddress[symbolHash] == address(0)) return;
    else {
      TokenAddress[symbolHash] = address(0);
      uint256 lastTokenIndex = TokenSymbols.length - 1;
      for (uint256 i; i < lastTokenIndex;) {
        if (keccak256(abi.encode(TokenSymbols[i])) == symbolHash) {
          TokenSymbols[i] = TokenSymbols[lastTokenIndex];
          break;
        }
        unchecked { i++; }
      }
      TokenSymbols.pop();
    }
  }

  /// @notice Set the admin status of an address
  /// @param account Ethereum address of the admin
  /// @param status true = grant admin rights, false = revoke admin rights
  /// @dev Accounts with admin status are permitted to call admin_ functions.
  function owner_setAdminStatus(address account, bool status) external ownerOnly {
    if (account == address(0)) revert AddressIsZero();
    emit AdminStatusUpdated(account, status);
    if (isAdmin[account] != status) isAdmin[account] = status;
  }

  /// @notice Change contract owner
  /// @param account Ethereum address of the new owner
  /// @dev The owner may call any owner_ or admin_ function.
  function owner_setOwner(address account) external ownerOnly {
    if (account == address(0)) revert AddressIsZero();
    owner = account;
  }

  /// @notice Withdraw an amount of the specified token from the contract
  /// @param token Ethereum address of the token contract
  /// @param destination Ethereum address of the withdrawal recipient
  /// @param amount Amount of funds to withdraw (in full decimals for the token)
  /// @dev Withdrawal does not depend on the token being registered with the contract at the time.
  function owner_withdrawFunds(address token, address destination, uint256 amount) external ownerOnly {
    if (destination == address(0)) revert AddressIsZero();
    assert(ERC20(token).transfer(destination, amount));
  }

  /// @notice Set up a new beneficiary for a token
  /// @param symbol 3 or 4 letter token 'ticker' (must be a registered token)
  /// @param account Ethereum address of the beneficiary
  /// @param totalAward Total amount being awarded (in full decimals for the token)
  /// @param numPayments Number of equal monthly payments to split the total award into (any excess from division is included in the first payment)
  /// @param startDate Date for the initial payment as a comma separated array, e.g.: 23rd July 2023 would be [23,7,2023]
  /// @dev Emits a BeneficiaryAdded log containing the input details for accounting.
  function admin_addBeneficiary(string calldata symbol, address account, uint256 totalAward, uint256 numPayments, Date calldata startDate) external ownerOrAdminOnly {
    if (account == address(0)) revert AddressIsZero();
    if (totalAward == 0) revert AmountIsZero();
    if (numPayments == 0) revert NumPaymentsIsZero();

    address token = getTokenAddress(symbol);
    bytes32 key = keccak256(abi.encode(token, account));

    if (NumPaymentsRemaining[key] != 0) revert AccountAlreadyExists();

    TokenSubscribers[token].push(account);
    uint256 startTimestamp = dateToTimestamp(startDate);

    NextPaymentDue[key] = startTimestamp;
    NumPaymentsRemaining[key] = numPayments;
    (FirstPaymentAmount[key], RegularPaymentAmount[key]) = calcPaymentAmounts(numPayments, totalAward);
    Obligation[token] += totalAward;
    emit BeneficiaryAdded(token, account, totalAward, numPayments, startTimestamp);
  }

  /// @notice Remove an existing beneficiary of a token
  /// @param symbol 3 or 4 letter token 'ticker'
  /// @param account Ethereum address of the beneficiary being removed
  /// @dev Ends payments of this token to the beneficiary. Emits a BeneficiaryRemoved log containing the closing state for accounting.
  function admin_removeBeneficiary(string calldata symbol, address account) external ownerOrAdminOnly {
    address token = getTokenAddress(symbol);
    tokenUnsubscribe(token, account);

    bytes32 key = keccak256(abi.encode(token, account));
    uint256 numPaymentsOutstanding = NumPaymentsRemaining[key];
    uint256 awardRemainder = calcAmountDue(key, numPaymentsOutstanding);
    NextPaymentDue[key] = 0;
    NumPaymentsRemaining[key] = 0;
    FirstPaymentAmount[key] = 0;
    RegularPaymentAmount[key] = 0;
    Obligation[token] -= awardRemainder;
    emit BeneficiaryRemoved(token, account, awardRemainder, numPaymentsOutstanding);
  }

  /// @notice Pays all outstanding amounts due, across all tokens and beneficiaries
  /// @dev The contract must hold enough funds to make ALL the payments or the method will fail and no payments will be made.
  function admin_runPayments() external ownerOrAdminOnly {
    uint256 numTokens = TokenSymbols.length;

    for (uint256 i; i < numTokens;) {
      uint256 paid;
      address token = getTokenAddress(TokenSymbols[i]);
      address[] memory accounts = TokenSubscribers[token];
      uint256 accountsLength = accounts.length;

      for (uint256 j; j < accountsLength;) {
        paid += makePaymentIfRequired(token, accounts[j]);
        unchecked { j++; }
      }

      Obligation[token] -= paid;
      unchecked { i++; }
    }
  }

  /// @notice Pays anything due to a single beneficiary for a specific token
  /// @param symbol 3 or 4 letter token 'ticker'
  /// @param account Ethereum address of the beneficiary
  function admin_runSinglePayment(string calldata symbol, address account) external ownerOrAdminOnly {
    address token = getTokenAddress(symbol);
    Obligation[token] -= makePaymentIfRequired(token, account);
  }

  /// @notice Allows a beneficiary to claim any unpaid funds due to date
  /// @dev Pays out for all tokens the beneficiary is entitled to.
  function claim() external {
    uint256 numTokens = TokenSymbols.length;

    for (uint256 i; i < numTokens;) {
      address token = getTokenAddress(TokenSymbols[i]);
      Obligation[token] -= makePaymentIfRequired(token, msg.sender);
      unchecked { i++; }
    }
  }