import {
  type AbiContract,
  type DecodedMessageBody,
  type KeyPair, type ResultOfProcessMessage,
  type TonClient
} from '@eversdk/core'
import { type Giver } from '../giver'
import { Global } from '../global'
import { error } from './constants'
import { createPayload } from './payload'
import { generateRandomKeyPair } from '../utils/generateRandomKeyPair'

export enum AccountType {
  notFound = '-1',
  unInit = '0',
  active = '1',
  frozen = '2',
  nonExist = '3'
}

export type CompiledContractConfig = {
  address?: string
  initial?: Record<string, any>
  keys?: KeyPair
}

export type ContractOptions = {
  client?: TonClient
  giver?: Giver
}

export interface ResultOfCall {
  out: any
  result: ResultOfProcessMessage
}

export class Contract {
  private readonly abi: {
    type: 'Contract'
    value: AbiContract
  }

  private readonly initial?: Record<string, any>
  private readonly tvc?: string

  private readonly client?: TonClient
  private readonly giver?: Giver

  private _address?: string
  private _keys?: KeyPair
  private _lastTransactionLogicTime: string

  constructor (
    config: {
      address: string
      keys?: KeyPair
      abi?: AbiContract
      initial?: Record<string, any>
      tvc?: string
    } | {
      address?: string
      keys?: KeyPair
      abi: AbiContract
      initial: Record<string, any>
      tvc: string
    },
    options: {
      client?: TonClient
      giver?: Giver
    } = {}
  ) {
    this._address = config.address
    this._keys = config.keys
    this._lastTransactionLogicTime = '0'
    this.abi = {
      type: 'Contract',
      value: config.abi ?? {}
    }
    this.initial = config.initial ?? {}
    this.tvc = config.tvc

    this.client = options.client ?? Global.client
    this.giver = options.giver ?? Global.giver
  }

  /**
   * Calculates the address only once. Next time returns the already calculated address
   * You can use this if you want to know the address of the contract before deploying
   * @example
   *   const contract = new Contract(...)
   *   const address = await contract.address()
   * @return
   *   '0:97b53be2604579e89bd0077a5456456857792eb2ff09849d14321fc2c167f29e'
   */
  public async address (): Promise<string> {
    if (this._address !== undefined)
      return this._address

    if (this.client === undefined)
      throw error.noClient

    if (this.tvc === undefined)
      throw new Error('Contract tvc is undefined')

    this._address = (await this.client.abi.encode_message({
      abi: this.abi,
      signer: {
        type: 'Keys',
        keys: await this.keys()
      },
      deploy_set: {
        tvc: this.tvc,
        initial_data: this.initial
      }
    })).address
    return this._address
  }

  /**
   * Generates a random key pair if no keys are given. Next time returns the already generated key pair
   * @example
   *   const contract = new Contract(...)
   *   const address = await contract.keyPair()
   * @return
   *   '0:97b53be2604579e89bd0077a5456456857792eb2ff09849d14321fc2c167f29e'
   */
  public async keys (): Promise<KeyPair> {
    if (this._keys !== undefined)
      return this._keys

    if (this.client === undefined)
      throw error.noClient

    if (this.tvc === undefined)
      throw new Error('Contract tvc is undefined')

    this._keys = await generateRandomKeyPair(this.client)
    return this._keys
  }

  /**
   * Return contract balance
   * @example
   *   const contract = new Contract(...)
   *   const address = await contract.balance()
   * @return
   *   10000000000n
   */
  public async balance (): Promise<bigint> {
    if (this.client === undefined)
      throw error.noClient

    const result: any[] = (await this.client.net.query_collection({
      collection: 'accounts',
      filter: {
        id: {
          eq: await this.address()
        },
        last_trans_lt: {
          ge: this._lastTransactionLogicTime
        }
      },
      result: 'balance'
    })).result
    return (result !== undefined && result.length > 0) ? BigInt(result[0].balance) : BigInt(0)
  }

  /**
   * Return contract account type
   * @example
   *  const contract = new Contract(...)
   *  const address = await contract.accountType()
   * @return
   *   1
   */
  public async accountType (): Promise<AccountType> {
    if (this.client === undefined)
      throw error.noClient

    const result: any[] = (await this.client.net.query_collection({
      collection: 'accounts',
      filter: {
        id: {
          eq: await this.address()
        },
        last_trans_lt: {
          ge: this._lastTransactionLogicTime
        }
      },
      result: 'acc_type'
    })).result
    return (result !== undefined && result.length > 0) ? result[0].acc_type.toString() : AccountType.nonExist
  }

  /**
   * Run contract locally
   * @param method Method name
   * @param input
   * @example
   *   const contract = new Contract(...)
   *   const address = await contract.runMethod('getHistory', { offset: 1 })
   * @return { DecodedMessageBody }
   */
  public async runMethod (method: string, input: any = {}): Promise<DecodedMessageBody> {
    if (this.client === undefined)
      throw error.noClient

    //////////////////////
    // Read Bag Of Cell //
    //////////////////////
    const bagOfCell = (await this.client.net.query_collection({
      collection: 'accounts',
      filter: {
        id: {
          eq: await this.address()
        }
      },
      result: 'boc'
    })).result[0].boc

    /////////////////////////////
    // Encode external message //
    /////////////////////////////
    const encodedMessage = await this.client.abi.encode_message({
      abi: this.abi,
      signer: {
        type: 'None'
      },
      call_set: {
        function_name: method,
        input
      },
      address: this._address
    })

    /////////////////////////////////////////
    // Execute contract on virtual machine //
    /////////////////////////////////////////
    const outputMessage = (await this.client.tvm.run_tvm({
      message: encodedMessage.message,
      account: bagOfCell
    })).out_messages[0]

    ///////////////////////////
    // Decode output message //
    ///////////////////////////
    return await this.client.abi.decode_message({
      abi: this.abi,
      message: outputMessage
    })
  }

  /**
   * External call
   * @param method Method name
   * @param input
   * @param [keys] Use it if you want to call contact with keys. `this.keys` used by default.
   * @example
   *   const contract = new Contract(...)
   *   const address = await contract.runMethod('getHistory', { offset: 1 })
   */
  public async callMethod (method: string, input: any = {}, keys?: KeyPair): Promise<{
    out: any
    result: ResultOfProcessMessage
  }> {
    if (this.client === undefined)
      throw error.noClient

    ///////////////////////////////
    // Generate external message //
    ///////////////////////////////
    const resultOfProcessMessage: ResultOfProcessMessage = await this.client.processing.process_message({
      message_encode_params: {
        abi: this.abi,
        signer: {
          type: 'Keys',
          keys: keys ?? await this.keys()
        },
        address: await this.address(),
        call_set: {
          function_name: method,
          input
        }
      },
      send_events: false
    })

    ////////////////////
    // Decode message //
    ////////////////////
    this._lastTransactionLogicTime = resultOfProcessMessage.transaction.lt.toString() ?? this._lastTransactionLogicTime
    return {
      out: resultOfProcessMessage.decoded?.output ?? {},
      result: resultOfProcessMessage
    }
  }

  /**
   * Return last transaction logic time
   * @example
   *   const contract = new Contract(...)
   *   contract.lastTransactionLogicTime
   * @return
   *   '0'
   */
  get lastTransactionLogicTime (): string {
    return this._lastTransactionLogicTime
  }

  /**
   * Deploy
   * @param value Deployment in nano coins
   * @param input constructor data
   * @param useGiver Send coins from giver before deployment if true
   * @param timeout How long to wait coins from giver on contract in milliseconds
   */
  public async _deploy (
    value: string | number | bigint,
    input: any = {},
    useGiver: boolean = true,
    timeout: number = 60000
  ): Promise<ResultOfProcessMessage> {
    ////////////////////////////
    // Check all requirements //
    ////////////////////////////
    const accountType = await this.accountType()
    if (accountType === AccountType.active)
      throw error.contractAlreadyDeployed

    if (this.client === undefined)
      throw error.noClient

    if (this.tvc === undefined)
      throw error.noTvc

    if (useGiver && this.giver === undefined)
      throw error.noGiver

    ///////////////
    // Use giver //
    ///////////////
    const to = await this.address()
    if (useGiver && this.giver !== undefined)
      await this.giver.send({ to, value })

    /////////////////////////////
    // Waiting for balance > 0 //
    /////////////////////////////
    await this.client.net.wait_for_collection({
      collection: 'accounts',
      filter: {
        id: {
          eq: await this.address()
        },
        data: {
          eq: null
        },
        code: {
          eq: null
        },
        balance: {
          gt: '0'
        }
      },
      result: 'last_trans_lt',
      timeout
    })

    ////////////
    // Deploy //
    ////////////
    const resultOfProcessMessage: ResultOfProcessMessage = await this.client.processing.process_message({
      message_encode_params: {
        abi: this.abi,
        signer: {
          type: 'Keys',
          keys: await this.keys()
        },
        deploy_set: {
          tvc: this.tvc,
          initial_data: this.initial
        },
        call_set: {
          function_name: 'constructor',
          input
        }
      },
      send_events: false
    })
    this._lastTransactionLogicTime = resultOfProcessMessage.transaction.lt ?? this._lastTransactionLogicTime
    return resultOfProcessMessage
  }

  /**
   * Use this if you want to wait for a transaction from one contract to another
   * @example
   *   const sender = new SenderContract(...)
   *   const receiver = new ReceiverContract(...)
   *   const to = await receiver.address()
   *   await sender.call.send({to, value: 1_000_000_000})
   *   const waitingResult = await receiver.wait(5000)
   * @param timeout Time in milliseconds
   * @return
   *   true
   */
  public async wait (timeout: number = 60000): Promise<boolean> {
    if (this.client === undefined)
      throw error.noClient

    try {
      const queryCollectionResult = await this.client.net.wait_for_collection({
        collection: 'accounts',
        filter: {
          id: {
            eq: await this.address()
          },
          last_trans_lt: {
            gt: this._lastTransactionLogicTime
          }
        },
        result: 'last_trans_lt',
        timeout
      })
      const result = queryCollectionResult.result
      this._lastTransactionLogicTime = result.last_trans_lt ?? '0'
      return true
    } catch (e: any) {}
    return false
  }

  /**
   * Create payload
   * @example
   *  const contract = new Contract(...)
   *  const payload = await contract.createPayload('bet', { luckyNumber: 5})
   * @param method Method name
   * @param input
   */
  public async createPayload (method: string, input: any = {}): Promise<string> {
    if (this.client === undefined)
      throw error.noClient

    return await createPayload(this.abi.value, method, input, this.client)
  }
}
