import {
  AccountBalanceQuery,
  AccountId,
  Client,
  Hbar,
  NftId,
  PrivateKey,
  PublicKey,
  Status,
  TokenId,
  TokenMintTransaction,
  TokenNftInfo,
  TokenNftInfoQuery,
  Transaction,
  TransactionReceipt,
  TransferTransaction,
} from '@hashgraph/sdk';
import keccak256 from 'keccak256';
import { CONFIRMATION_STATUS, TOKEN_ID } from './config/constants.config';
import { logger } from './config/logger.config';
import { callContractFunc } from './contract.utils';
import { getManagerInfo, ManagerInfo } from './manager';

interface SerialInfo {
  serial: string;
  node: string;
}

interface TransactionSignature {
  signerPublicKey: PublicKey;
  signature: Uint8Array;
}

// interface File {
//   uri: string;
//   type: string;
//   metadata: object;
//   metadata_uri: object;
// }

// HIP-412 metadata not currently supported by hedera 100 byte metadata limit
interface NFTMetadata {
  name: string;
  creator: string;
  // creatorDID: string;
  description: string;
  // image: string;
  // type: string;
  // files: File[];
  // format: string;
  // properties: object[];
  // localization: object[];
}

export class HashgraphNames {
  operatorId: AccountId;
  operatorKey: PrivateKey;
  supplyKey: PrivateKey;
  client: Client;
  tokenId: TokenId = TokenId.fromString(TOKEN_ID);

  constructor(operatorId: string, operatorKey: string, supplyKey: string) {
    this.operatorId = AccountId.fromString(operatorId);
    this.operatorKey = PrivateKey.fromString(operatorKey);
    this.supplyKey = PrivateKey.fromString(supplyKey);

    this.client = Client.forTestnet().setOperator(this.operatorId, this.operatorKey);
  }

  static generateMetadata = (domain: string): NFTMetadata => {
    const metadata: NFTMetadata = {
      name: domain,
      creator: 'piefi labs',
      // creatorDID: '',
      description: 'Hashgraph Naming service domain',
      // image: '[cid or path to NFT\'s image]',
      // type: 'image/jpeg', // TODO: Change this to whatever file type we end up generating for the NFT images
      // files: [],
      // format: 'none',
      // properties: [],
      // localization: [],
    };

    return metadata;
  };

  /**
 * @description Simple wrapper around HTS TokenMintTransaction()
 * @param metadata: {Buffer} The metadata to include on the newly minted NFT
 * @returns {Promise<TransactionReceipt>}
 */
  private mintNFT = async (
    metadata: NFTMetadata,
  ): Promise<TransactionReceipt> => {
    try {
      const mintTx = new TokenMintTransaction()
        .setTokenId(this.tokenId)
        .setMetadata([Buffer.from(JSON.stringify(metadata))])
        .freezeWith(this.client);
      const mintTxSign = await mintTx.sign(this.supplyKey);
      const mintTxSubmit = await mintTxSign.execute(this.client);
      const mintRx = await mintTxSubmit.getReceipt(this.client);
      if (mintRx.status._code !== Status.Success._code) {
        throw new Error('TokenMintTransaction failed');
      }
      return mintRx;
    } catch (err) {
      logger.error(err);
      throw new Error('Failed to mint NFT');
    }
  };

  /**
 * @description Check if a token is associated with a specific account
 * @param accountId: {AccountId} The account to check if the domain NFT is associated
 * @returns {Promise<boolean>}
 */
  private isTokenAssociatedToAccount = async (
    accountId: AccountId,
  ): Promise<boolean> => {
    try {
      const balanceCheckTx = await new AccountBalanceQuery()
        .setAccountId(accountId)
        .execute(this.client);

      if (!balanceCheckTx) {
        throw new Error('AccountBalanceQuery Failed');
      }

      const { tokens } = balanceCheckTx;
      if (tokens) {
        const tokenOfInterest = tokens._map.get(this.tokenId.toString());
        return tokenOfInterest !== undefined;
      }

      return false;
    } catch (err) {
      logger.error(err);
      throw new Error('Failed to determine if token is associated to account');
    }
  };

  /**
 * @description Check if a domain exists in the registry
 * @param domainHash: {Buffer} The hash of the domain to check
 * @returns {Promise<boolean>}
 */
  private checkDomainExists = async (
    domainHash: Buffer,
  ): Promise<boolean> => {
    try {
      const { serial } = await this.getDomainSerial(domainHash);
      return Number(serial) !== 0;
    } catch (err) {
      logger.error(err);
      throw new Error('Failed to check if domains exists');
    }
  };

  /**
 * @description Register a domain in the smart contract Registry
 * @param domainHash: {Buffer} The hash of the domain to add to the Registry
 * @param serial: {number} The serial of the NFT to register
 * @returns {Promise<number>}
 */
  private registerDomain = async (
    domainHash: Buffer,
    serial: number,
  ): Promise<number> => {
    try {
    // Get manager contract from env
      const managerInfo = getManagerInfo();

      // Add if not present
      await callContractFunc(
        managerInfo.contract.id,
        managerInfo.abi,
        'addRecord',
        [`0x${domainHash.toString('hex')}`, `${serial}`],
        this.client,
      );
      return CONFIRMATION_STATUS;
    } catch (err) {
      logger.error(err);
      throw new Error('Failed to register Domain');
    }
  };

  /**
 * @description Mints a new domain NFT and records it in the registry
 * @throws {@link InternalServerError}
 * @param domain {string} The domain to mint
 * @param ownerId {string} The owner of the domain to mint
 * @returns {Promise<number>}
 */
  mintDomain = async (
    domain: string,
    ownerId: string,
  ): Promise<number> => {
    let NFTSerial;
    let domainHash;

    const accountId = AccountId.fromString(ownerId);

    try {
      domainHash = HashgraphNames.generateNFTHash(domain);

      const domainExists = await this.checkDomainExists(domainHash);
      if (domainExists) throw new Error('Domain already exists in the registry');

      const isAssociated = await this.isTokenAssociatedToAccount(accountId);
      if (!isAssociated) throw new Error('Wallet must first be associated before a token can be minted');

      // Mint the NFT
      const metadata = HashgraphNames.generateMetadata(domain);
      const mintRx = await this.mintNFT(metadata);
      NFTSerial = Number(mintRx.serials[0]);

      // Register the domain in the Registry
      await this.registerDomain(domainHash, NFTSerial);

      return CONFIRMATION_STATUS;
    } catch (err) {
      logger.error(err);
      throw new Error('Failed to mint domain.');
    }
  };

  /**
 * @description Helper function to convert an Uint8Array into an Hedera Transaction type
 * @param transactionBytes: {Uint8Array} The transaction bytes to be converted
 */
  private static bytesToTransaction = (transactionBytes: Uint8Array): Transaction => {
    const uint8Array = new Uint8Array(transactionBytes);
    const transaction: Transaction = Transaction.fromBytes(uint8Array);
    return transaction;
  };

  /**
 * @description Executes an HTS TransferTransaction
 * @param ownerSignature: {TransactionSignature} The signature information for the NFT owner
 * @param receiverSignature: {TransactionSignature} The signature information for the NFT receiver
 * @param transactionBytes: {Uint8Array} The transaction bytes to be executed
 * @returns {Promise<number>}
 */
  transferDomain = async (
    ownerSignature: TransactionSignature,
    receiverSignature: TransactionSignature,
    transactionBytes: Uint8Array,
  ): Promise<number> => {
    try {
      const transaction: Transaction = HashgraphNames.bytesToTransaction(transactionBytes);
      transaction
        .addSignature(ownerSignature.signerPublicKey, ownerSignature.signature)
        .addSignature(receiverSignature.signerPublicKey, receiverSignature.signature);

      const submitTransaction = await transaction.execute(this.client);
      const receipt = await submitTransaction.getReceipt(this.client);
      if (receipt.status._code !== Status.Success._code) {
        throw new Error('TransferTransaction failed');
      }
    } catch (err) {
      throw new Error('Transfer Domain failed');
    }
    return CONFIRMATION_STATUS;
  };

  /**
 * @description Signs a Hedera transaction
 * @param signerKey: {string} The private key with which to sign the transaction
 * @param transactionBytes: {Uint8Array} The bytes for the transaction to be signed
 * @returns {Promise<Uint8Array>}
 */
  static transferTransactionSign = (signerKey: string, transactionBytes: Uint8Array): TransactionSignature => {
    const transaction: Transaction = HashgraphNames.bytesToTransaction(transactionBytes);
    const signerPVKey = PrivateKey.fromString(signerKey);
    const signature = signerPVKey.signTransaction(transaction);
    return { signerPublicKey: signerPVKey.publicKey, signature };
  };

  /**
 * @description Creates a HTS TransferTransaction and returns it as an Uint8Array
 * @param domain: {string} The domain for the NFT to transfer
 * @param NFTOwner: {string} The account id of the NFT owner
 * @param NFTReceiver: {string} The account id of the NFT receiver
 * @param purchasePrice: {number} The amount in tinyBar for which the NFT is being purchased
 * @returns {Uint8Array}
 */
  transferTransactionCreate = async (
    domain: string,
    NFTOwner: string,
    NFTReceiver: string,
    purchasePrice: number,
  ): Promise<Uint8Array> => {
    try {
      const fromIdNFT = AccountId.fromString(NFTOwner);
      const toIdNFT = AccountId.fromString(NFTReceiver);
      const { serial } = await this.getNFTSerialString(domain);

      const nodeId = [new AccountId(3)];

      const tokenTransferTx = new TransferTransaction()
        .addNftTransfer(this.tokenId, serial, fromIdNFT, toIdNFT)
        .addHbarTransfer(toIdNFT, Hbar.fromTinybars(-1 * purchasePrice))
        .addHbarTransfer(fromIdNFT, Hbar.fromTinybars(purchasePrice))
        .setNodeAccountIds(nodeId)
        .freezeWith(this.client);

      return tokenTransferTx.toBytes();
    } catch (err) {
      throw new Error('MultiSig transaction create failed');
    }
  };

  /**
   * @description Generate a hash of the provided domain string
   * @param domain: {string} The domain string to hash
   * @returns {Buffer}
   */
  static generateNFTHash = (domain: string): Buffer => {
    const subDomains = domain.split('.').reverse();
    return subDomains.reduce(
      (prev, curr) => keccak256(prev + curr),
      Buffer.from([0]),
    );
  };

  /**
   * @description Simple wrapper around callContractFunc for the getSerial smart contract function
   * @param domainHash: {Buffer} The hash of the domain to query
   * @param begin: {number} The begin index in the array of nodes of the manager
   * @param end: {number} The end index in the array of nodes of the manager
   * @returns {Promise<SerialInfo>}
   */
  private callGetSerial = async (
    domainHash: Buffer,
    begin: number,
    end: number,
  ): Promise<SerialInfo> => {
    try {
      const managerInfo: ManagerInfo = getManagerInfo();

      const result = await callContractFunc(
        managerInfo.contract.id,
        managerInfo.abi,
        'getSerial',
        [`0x${domainHash.toString('hex')}`, `${begin}`, `${end}`],
        this.client,
      );
      return { serial: result[0], node: result[1] };
    } catch (err) {
      logger.error(err);
      throw new Error('Failed to get owner');
    }
  };

  /**
 * @description Query the registry for the owner of a domain
 * @param domainHash: {Buffer} The hash of the domain to query
 * @returns {Promise<SerialInfo>}
 */
  private getDomainSerial = async (domainHash: Buffer): Promise<SerialInfo> => {
    let decodedResult: SerialInfo = { serial: '0', node: '0' };
    try {
      const managerInfo = getManagerInfo();

      const numNodes: number = (
        await callContractFunc(
          managerInfo.contract.id,
          managerInfo.abi,
          'getNumNodes',
          [],
          this.client,
        )
      )[0];

      const chunkSize = 100;
      let begin = 0;
      let end = 0;
      for (let i = 0; end < numNodes; i += 1) {
        end = Number((i + 1) * chunkSize);
        // eslint-disable-next-line no-await-in-loop
        decodedResult = await this.callGetSerial(domainHash, begin, end);
        if (Number(decodedResult.serial) !== Number(0)) {
        // Found the owner
          break;
        }
        begin = end;
      }
      return decodedResult;
    } catch (err) {
      logger.error(err);
      throw new Error('Failed to get owner');
    }
  };

  /**
 * @description Simple wrapper around HTS TokenNftInfoQuery()
 * @param serial: {number} The serial of the NFT to query
 * @returns {Promise<TokenNftInfo>}
 */
  private getTokenNFTInfo = async (
    serial: number,
  ): Promise<TokenNftInfo> => {
    try {
      const nftId = new NftId(this.tokenId, serial);
      const nftInfo = await new TokenNftInfoQuery()
        .setNftId(nftId)
        .execute(this.client);
      return nftInfo[0];
    } catch (err) {
      logger.error(err);
      throw new Error('Get NFT info failed');
    }
  };

  /**
 * @description Wrapper around getDomainSerial() that takes a string of the domain
 * @param domain: {string} The domain to query
 * @returns {Promise<SerialInfo>}
 */
  getNFTSerialString = async (domain: string): Promise<SerialInfo> => this.getDomainSerial(HashgraphNames.generateNFTHash(domain));

  /**
 * @description Gets the serial for the domain, then queries for the AccountId who owns
 * that domain.
 * @param domain: {string} The domain to query
 * @returns {Promise<AccountId>}
 */
  getWallet = async (domain: string): Promise<AccountId> => {
    try {
      const { serial } = await this.getNFTSerialString(domain);
      const { accountId } = await this.getTokenNFTInfo(Number(serial));
      return accountId;
    } catch (err) {
      logger.error(err);
      throw new Error('Failed to get wallet');
    }
  };
}
