import BufferUtils from "../../utils/buffer.utils";
import CommonUtils from "../../utils/common.utils";
import Script from "../script/script";
import Input from "./input/input";
import Output from "./output";
import type { ITransaction, TxVerifyOptions } from "./interfaces";
import BufferWriter from "../../encoding/bufferwriter";
import { Opcode } from "../script/opcode";
import Hash from "../../crypto/hash";
import ValidationUtils from "../../utils/validation.utils";
import type Address from "../address/address";
import ScriptFactory from "../script/script.factory";
import type { IScript } from "../../common/interfaces";
import BufferReader from "../../encoding/bufferreader";
import PublicKeyHashInput from "./input/publickeyhash";
import PublicKeyTemplateInput from "./input/publickeytemplate";
import ScriptTemplateInput from "./input/scripttemplate";

/**
 * Represents a transaction, a set of inputs and outputs to change ownership of tokens
 */
export default class Transaction implements ITransaction {

  public static readonly CURRENT_VERSION = 0;
  public static readonly FEE_PER_BYTE = 3;

  // Minimum amount for an output for it not to be considered a dust output
  public static readonly DUST_AMOUNT = 546;

  // Max amount of satoshis in circulation
  public static readonly MAX_MONEY = 21000000 * 1e8;

  // nlocktime limit to be considered block height rather than a timestamp
  public static readonly NLOCKTIME_BLOCKHEIGHT_LIMIT = 5e8;

  // Max value for an unsigned 32 bit value
  public static readonly NLOCKTIME_MAX_VALUE = 4294967295;

  public version: number;
  public inputs: Input[];
  public outputs: Output[];
  public nLockTime: number;

  private _feePerByte?: number;
  private _fee?: number
  private _changeIndex?: number;
  private _changeScript?: Script;

  constructor(serializedTx?: ITransaction | string | Uint8Array) {
    this.version = Transaction.CURRENT_VERSION;
    this.inputs = [];
    this.outputs = [];
    this.nLockTime = 0;

    if (serializedTx) {
      if (BufferUtils.isBuffer(serializedTx)) {
        this.fromBuffer(serializedTx);
      } else if (CommonUtils.isHexa(serializedTx as string)) {
        this.fromString(serializedTx as string);
      } else if (typeof serializedTx === 'object' && serializedTx !== null) {
        this.fromObject(serializedTx);
      } else {
        throw new TypeError("Must provide an object or string to deserialize a transaction");
      }
    }
  }

  public get id(): string {
    let buf = new BufferWriter().write(this._getTxIdem()).write(this._getTxSatisfier()).toBuffer();
    return BufferUtils.bufferToHex(Hash.sha256sha256(buf).reverse());
  }

  public get idem(): string {
    return BufferUtils.bufferToHex(this._getTxIdem().reverse());
  }

  public get outputAmount(): bigint {
    return this.outputs.reduce((total, output) => total + output.value, 0n);
  }

  public get inputAmount(): bigint {
    return this.inputs.reduce((total, input) => total + input.amount, 0n);
  }

  private _getTxIdem(): Uint8Array {
    return Hash.sha256sha256(this._toIdemBuffer());
  }
  
  private _getTxSatisfier(): Uint8Array {
    return Hash.sha256sha256(this._toSatisfierBuffer());
  }

  /**
   * Create a 'shallow' copy of the transaction, by serializing and deserializing.
   * it dropping any additional information that inputs and outputs may have hold
   *
   * @param transaction
   */
  public static shallowCopy(transaction: Transaction): Transaction {
    return new Transaction(transaction.toBuffer());
  }

  /**
   * Analogous to nexad's IsCoinBase function in transaction.h
   */
  public isCoinbase(): boolean {
    return this.inputs.length === 0;
  }

  /**
   * Retrieve a possible error that could appear when trying to serialize and
   * broadcast this transaction.
   *
   * @param opts allows to skip certain tests.
   */
  public getSerializationError(opts?: TxVerifyOptions): Error | undefined {
    if (this._invalidAmount()) {
      return new Error('Output satoshis are invalid');
    }

    if (this.outputs.length > 256) {
      return new Error('Too many outputs (> 256)');
    }
    if (this.inputs.length > 256) {
      return new Error('Too many inputs (> 256)');
    }

    let unspent = this.getUnspentValue();
    let unspentError: Error | undefined;
    if (unspent < 0) {
      if (!opts?.disableMoreOutputThanInput) {
        unspentError = new Error('Invalid outputs amount sum');
      }
    } else {
      unspentError = this._hasFeeError(unspent);
    }

    return unspentError ||
      this._hasDustOutputs(opts) ||
      this._isMissingSignatures(opts);
  }

  private _invalidAmount(): boolean {
    return this.outputs.some(out => out.invalidValue());
  }
  
  private _hasDustOutputs(opts?: TxVerifyOptions): Error | undefined {
    if (opts?.disableDustOutputs) {
      return;
    }
    
    let hasDust = this.outputs.some(output => output.value < Transaction.DUST_AMOUNT && !output.scriptPubKey.isDataOut());
    return hasDust ? new Error('Dust amount detected in one output') : undefined;
  }

  private _hasFeeError(unspent: bigint): Error | undefined {
    if (this._fee != null && BigInt(this._fee) !== unspent) {
      return new Error(`Unspent value is ${unspent} but specified fee is ${this._fee}`);
    }
  }

  private _estimateFee(): number {
    let estimatedSize = this._estimateSize();
    let available = this.getUnspentValue();
    let feeRate = this._feePerByte || Transaction.FEE_PER_BYTE;
    const calcFee = (size: number): number => {
      return size * feeRate;
    }
    let feeWithChange = Math.ceil(calcFee(estimatedSize) + calcFee(this._estimateSizeOfChangeOutput()));
    if (!this._changeScript || available <= feeWithChange || available - BigInt(feeWithChange) < Transaction.DUST_AMOUNT) {
      return Number(available);
    }
    return feeWithChange;
  }

  private _estimateSizeOfChangeOutput(): number {
    /* c8 ignore start */
    if (!this._changeScript) {
      return 0; // should not reach here
    }
    /* c8 ignore stop */

    let scriptLen = this._changeScript.toBuffer().length;
    // 1 byte for type + 8 bytes for amount + script size + actual script size
    return 1 + 8 + BufferWriter.varintBufNum(scriptLen).length + scriptLen;
  }

  private _estimateSize(): number {
    let result = 4 + 1; // locktime + version

    result += this.inputs.length < 253 ? 1 : 3;
    this.inputs.forEach(input => {
      result += input.estimateSize();
    });
    
    result += this.outputs.length < 253 ? 1 : 3;
    this.outputs.forEach(output => {
      result += output.toBufferWriter().toBuffer().length;
    });
  
    return result;
  }
  
  private _isMissingSignatures(opts?: TxVerifyOptions): Error | undefined {
    if (opts?.disableIsFullySigned) {
      return;
    }
    if (!this.isFullySigned()) {
      return new Error('Some inputs have not been fully signed');
    }
  }

  public isFullySigned(): boolean {
    if (this.inputs.some(input => input.isFullySigned === Input.prototype.isFullySigned)) {
      throw new Error("Unable to verify signature: Unrecognized script kind, or not enough information to execute script."
         + " This usually happens when creating a transaction from a serialized transaction")
    }
    return this.inputs.every(input => input.isFullySigned());
  }

  /**
   * @returns true if the transaction has enough info on all inputs to be correctly validated
   */
  public hasAllUtxoInfo(): boolean {
    return this.inputs.every(input => input.output != null);
  }

  public getUnspentValue(): bigint {
    return this.inputAmount - this.outputAmount;
  }

  /**
   * Calculates the fee of the transaction.
   *
   * If there's a fixed fee set, return that.
   *
   * If there is no change output set, the fee is the
   * total value of the outputs minus inputs. Note that
   * a serialized transaction only specifies the value
   * of its outputs. (The value of inputs are recorded
   * in the previous transaction outputs being spent.)
   * This method therefore raises a "MissingPreviousOutput"
   * error when called on a serialized transaction.
   *
   * If there's no fee set and no change address,
   * estimate the fee based on size.
   *
   * @return fee of this transaction in satoshis
   */
  public getFee(): number {
    if (this.isCoinbase()) {
      return 0;
    }
    if (this._fee != null) {
      return this._fee;
    }
    // if no change output is set, fees should equal all the unspent amount
    if (!this._changeScript) {
      return Number(this.getUnspentValue());
    }
    return this._estimateFee();
  }

  /**
   * Calculates the required fee of the transaction.
   * 
   * @remarks this method is different than getFee.
   *  while getFee return the current fee estimation, this method return how much fee is required according to the fee rate.
   * 
   * @returns the required fees of this transaction in satoshis
   */
  public estimateRequiredFee(): number {
    let feeRate = this._feePerByte || Transaction.FEE_PER_BYTE;
    return feeRate * this._estimateSize();
  }

  public clearSignatures(): void {
    this.inputs.forEach(input => input.clearSignatures());
  }

  /**
   * Retrieve a hexa string that can be used with nexad's CLI interface
   * (decoderawtransaction, sendrawtransaction)
   *
   * @param opts allows to skip certain tests.
   */
  public checkedSerialize(opts?: TxVerifyOptions): string {
    let serializationError = this.getSerializationError(opts);
    if (serializationError) {
      throw serializationError;
    }
    return this.toString();
  }

  public uncheckedSerialize = this.toString;

  public toString(): string {
    return BufferUtils.bufferToHex(this.toBuffer());
  }

  public inspect(): string {
    return `<Transaction: ${this}>`;
  }

  public fromString(string: string): this {
    return this.fromBuffer(BufferUtils.hexToBuffer(string));
  }

  /**
   * Retrieve a hexa string that can be used with nexad's CLI interface
   * (decoderawtransaction, sendrawtransaction)
   *
   * @param unsafe if true, skip all tests. if it's an object,
   * it's expected to contain a set of flags to skip certain tests.
   * 
   * @see {@link TxVerifyOptions}
   */
  public serialize(unsafe?: boolean | TxVerifyOptions): string {
    if (true === unsafe || (typeof unsafe === 'object' && unsafe !== null && unsafe.disableAll)) {
      return this.uncheckedSerialize();
    } else {
      return this.checkedSerialize(typeof unsafe === 'object' && unsafe !== null ? unsafe : undefined);
    }
  }

  /**
   * Manually set the fee for this transaction. Beware that this resets all the signatures
   * for inputs.
   *
   * @param amount satoshis to be set as fees
   * @return this, for chaining
   */
  public setFee(amount: number): this {
    ValidationUtils.validateArgument(typeof amount === 'number', 'amount must be a number');
    this._fee = amount;
    this._updateChangeOutput();
    return this;
  }

  /**
   * Manually set the fee per Byte for this transaction. Beware that this resets all the signatures
   * for inputs.
   * fee per Byte will be ignored if fee property was set manually
   *
   * @param amount satoshis per Byte to be used as fee rate
   * @return this, for chaining
   */
  public setFeePerByte(amount: number): this {
    ValidationUtils.validateArgument(typeof amount === 'number', 'amount must be a number');
    this._feePerByte = amount;
    this._updateChangeOutput();
    return this;
  }

  /**
   * Add an output to the transaction.
   *
   * @param output the output to add.
   * @return this, for chaining
   */
  public addOutput(output: Output): this {
    ValidationUtils.validateArgumentType(output, Output, 'output');
    this.outputs.push(output);
    this._updateChangeOutput();
    return this;
  }

  public removeOutput(index: number): this {
    this._removeOutput(index);
    this._updateChangeOutput();
    return this;
  }

  private _removeOutput(index: number): void {
    this.outputs = this.outputs.filter((_, i) => i !== index);
  }

  /**
   * Remove all outputs from the transaction.
   *
   * @return this, for chaining
   */
  public clearOutputs(): this {
    this.outputs = [];
    this.clearSignatures();
    this._updateChangeOutput();
    return this;
  }

  public updateOutputAmount(index: number, sats: bigint | number): void {
    this.outputs[index].value = BigInt(sats);
    this._updateChangeOutput();
  }

  /**
   * Set the change address for this transaction
   *
   * Beware that this resets all the signatures for inputs.
   *
   * @param address An address for change to be sent to.
   * @return this, for chaining
   */
  public setChangeOutput(address: string | Address): this {
    ValidationUtils.validateArgument(address != null, 'address is required.');
    this._changeScript = ScriptFactory.buildOutFromAddress(address);
    this._updateChangeOutput();
    return this;
  }

  /**
   * @returns change output, if it exists
   */
  public getChangeOutput(): Output | undefined {
    if (this._changeIndex != null) {
      return this.outputs[this._changeIndex];
    }
    return undefined;
  }

  private _updateChangeOutput(): void {
    if (!this._changeScript) {
      return;
    }
    this.clearSignatures();
    if (this._changeIndex != null) {
      this._removeOutput(this._changeIndex);
    }
    let available = this.getUnspentValue();
    let fee = this.getFee();
    let changeAmount = available - BigInt(fee);

    if (changeAmount >= Transaction.DUST_AMOUNT) {
      this._changeIndex = this.outputs.length;
      this.outputs.push(new Output(changeAmount, this._changeScript));
    } else {
      this._changeIndex = undefined;
    }
  }

  /**
   * Add an input to this transaction, without checking that the input has information about
   * the output that it's spending.
   *
   * @param input the input to add
   * @return this, for chaining
   */
  public uncheckedAddInput(input: Input): this {
    ValidationUtils.validateArgumentType(input, Input, 'input');
    this.inputs.push(input);
    this._updateChangeOutput();
    return this;
  }

  /**
   * Add an input to this transaction. The input must be an instance of the `Input` class.
   * It should have information about the Output that it's spending, but if it's not already
   * set, two additional parameters, `outputScript` and `amount` can be provided.
   *
   * @param input
   * @param outputScript
   * @param amount
   * @return this, for chaining
   */
  public addInput(input: Input, outputScript?: IScript | Uint8Array | string, amount?: bigint | number): this {
    ValidationUtils.validateArgumentType(input, Input, 'input');
    if (input.output == null && (outputScript == null || amount == null)) {
      throw new Error('Need information about the UTXO script and amount');
    }
    if (input.output == null && outputScript != null && amount != null) {
      let scriptPubKey = outputScript instanceof Script ? outputScript : new Script(outputScript);
      input.output = new Output(BigInt(amount), scriptPubKey);
    }
    return this.uncheckedAddInput(input);
  }

  public removeInput(outpoint: string): this {
    this.inputs = this.inputs.filter(input => BufferUtils.bufferToHex(input.outpoint) != outpoint);
    this._updateChangeOutput();
    return this;
  }

  /**
   * Sets nLockTime so that transaction is not valid until the desired date or height.
   * Beware that this method will also set the inputs sequence number to max_int - 1
   * 
   * @remarks nLockTime considered as height if the value is between 0 - 499,999,999.
   *  above that considered as date (unix timestamp).
   * 
   * @see {@link NLOCKTIME_BLOCKHEIGHT_LIMIT}
   * 
   * @param locktime 
   * @returns 
   */
  public setLockTime(locktime: number): this {
    ValidationUtils.validateArgument(typeof locktime === 'number', 'locktime', 'must be a number');

    this.inputs.forEach(input => {
      if (input.sequenceNumber === Input.SEQUENCE_FINAL) {
        input.sequenceNumber = Input.SEQUENCE_FINAL - 1;
      }
    });

    this.nLockTime = locktime;
    return this;
  }

  /**
   *  Returns a semantic version of the transaction's nLockTime.
   *  If nLockTime is 0, it returns null,
   *  if it is < 500000000, it returns a block height (number)
   *  else it returns a Date object.
   */
  public getLockTime(): number | Date | null {
    if (!this.nLockTime) {
      return null;
    }
    if (this.nLockTime < Transaction.NLOCKTIME_BLOCKHEIGHT_LIMIT) {
      return this.nLockTime;
    }
    return new Date(1000 * this.nLockTime);
  }

  public toBuffer(): Uint8Array {
    return this.toBufferWriter().toBuffer();
  }

  public toBufferWriter(writer?: BufferWriter, withInputsScripts = true): BufferWriter {
    if (!writer) {
      writer = new BufferWriter();
    }
    writer.writeUInt8(this.version);

    writer.writeVarintNum(this.inputs.length);
    this.inputs.forEach(input => input.toBufferWriter(writer, withInputsScripts));

    writer.writeVarintNum(this.outputs.length);
    this.outputs.forEach(output => output.toBufferWriter(writer));

    writer.writeUInt32LE(this.nLockTime);
    return writer;
  }

  private _toIdemBuffer(): Uint8Array {
    let writer = new BufferWriter();
    return this.toBufferWriter(writer, false).toBuffer();
  }

  private _toSatisfierBuffer(): Uint8Array {
    let writer = new BufferWriter();

    writer.writeInt32LE(this.inputs.length);
    this.inputs.forEach(input => {
      writer.write(input.scriptSig.toBuffer());
      writer.writeUInt8(Opcode.OP_INVALIDOPCODE);
    });

    return writer.toBuffer();
  }

  public fromBuffer(buffer: Uint8Array): this {
    let reader = new BufferReader(buffer);
    return this.fromBufferReader(reader);
  }
  
  public fromBufferReader(reader: BufferReader): this {
    ValidationUtils.validateState(!reader.finished(), 'No transaction data received');
  
    this.version = reader.readUInt8();
    let sizeTxIns = reader.readVarintNum();
    for (let i = 0; i < sizeTxIns; i++) {
      this.inputs.push(Input.fromBufferReader(reader));
    }
    let sizeTxOuts = reader.readVarintNum();
    for (let i = 0; i < sizeTxOuts; i++) {
      this.outputs.push(Output.fromBufferReader(reader));
    }
    this.nLockTime = reader.readUInt32LE();
    return this;
  }

  public toJSON = this.toObject;

  public toObject(): ITransaction {
    let obj: ITransaction = {
      id: this.id,
      idem: this.idem,
      version: this.version,
      inputs: this.inputs.map(input => input.toObject()),
      outputs: this.outputs.map(output => output.toObject()),
      nLockTime: this.nLockTime
    };
    if (this._changeScript != null) {
      obj.changeScript = this._changeScript.toHex();
    }
    if (this._changeIndex != null) {
      obj.changeIndex = this._changeIndex;
    }
    if (this._fee != null) {
      obj.fee = this._fee;
    }
    if (this._feePerByte != null) {
      obj.feePerByte = this._feePerByte;
    }
    return obj;
  }

  public fromObject(transaction: ITransaction): this {
    ValidationUtils.validateArgument(typeof transaction === 'object' && transaction != null, 'transaction');
    if (transaction instanceof Transaction) {
      transaction = transaction.toObject();
    }

    this.nLockTime = transaction.nLockTime;
    this.version = transaction.version;

    for (let input of transaction.inputs) {
      if (input.output?.scriptPubKey == null) {
        this.uncheckedAddInput(new Input(input));
        continue;
      }

      let script = new Script(input.output.scriptPubKey);
      let txin: Input;
      if (script.isPublicKeyHashOut()) {
        txin = new PublicKeyHashInput(input);
      } else if (script.isPublicKeyTemplateOut()) {
        txin = new PublicKeyTemplateInput(input);
      } else if (script.isScriptTemplateOut()) {
        if ('templateData' in input) {
          txin = new ScriptTemplateInput(input);
        } else {
          txin = new Input(input);
        }
      } else {
        throw new Error(`Unsupported input script type: ${script}`);
      }
      this.addInput(txin);
    }

    for(let output of transaction.outputs) {
      this.addOutput(Output.fromObject(output));
    }

    if (transaction.changeIndex != null) {
      this._changeIndex = transaction.changeIndex;
    }
    if (transaction.changeScript != null) {
      this._changeScript = new Script(transaction.changeScript);
    }
    if (transaction.fee != null) {
      this._fee = transaction.fee;
    }
    if (transaction.feePerByte != null) {
      this._feePerByte = transaction.feePerByte;
    }
    this._checkConsistency(transaction);
    return this;
  }

  private _checkConsistency(transaction: ITransaction): void {
    if (this._changeIndex != null) {
      ValidationUtils.validateState(this._changeScript != null, 'Change script is expected.');
      ValidationUtils.validateState(this.outputs[this._changeIndex] != null, 'Change index points to undefined output.');
      ValidationUtils.validateState(this.outputs[this._changeIndex].scriptPubKey.toHex() ===
        this._changeScript?.toHex(), 'Change output has an unexpected script.');
    }
    if (transaction?.id) {
      ValidationUtils.validateState(transaction.id === this.id, 'Id in object does not match transaction id.');
    }
  }
}