import type { Networkish } from "../../common/types";
import ValidationUtils from "../../utils/validation.utils";
import { networks } from "../network/network-manager";
import AddressFormatter, { AddressType } from "./address-formatter";
import type { Network } from "../network/network";
import type { AddressDto } from "../../common/interfaces";
import CommonUtils from "../../utils/common.utils";
import PublicKey from "../../keys/publickey";
import Hash from "../../crypto/hash";
import Script from "../script/script";
import type { ScriptElement } from "../script/script";
import { Opcode } from "../script/opcode";
import BufferUtils from "../../utils/buffer.utils";
import BufferWriter from "../../encoding/bufferwriter";

export default class Address {

  public readonly data!: Uint8Array;
  public readonly network!: Network;
  public readonly type!: AddressType;

  /**
   * Instantiate an address from an address String or Uint8Array, a public key or script hash Uint8Array,
   * or an instance of {@link PublicKey} or {@link Script}.
   *
   * This is an immutable class, and if the first parameter provided to this constructor is an
   * `Address` instance, the same argument will be returned.
   *
   * An address has two key properties: `network` and `type`. The type is either
   * `AddressType.PayToPublicKeyHash` (value is the `'P2PKH'` string)
   * or `AddressType.PayToScriptTemplate` (the string `'P2ST'`). The network is an instance of {@link Network} or network name.
   * You can quickly check whether an address is of a given kind by using the methods
   * `isPayToPublicKeyHash` and `isPayToScriptTemplate`
   *
   * @example
   * ```javascript
   * // validate that an input field is valid
   * let error = Address.getValidationError(input, 'testnet');
   * if (!error) {
   *   let address = new Address(input, 'testnet');
   * } else {
   *   // invalid network or checksum (typo?)
   *   let message = error.messsage;
   * }
   *
   * // get an address from a public key
   * let address = Address.fromPublicKey(publicKey, 'testnet').toString();
   * ```
   *
   * @param data The encoded data in various formats
   * @param network The network: 'mainnet' (default) or 'testnet'
   * @param type The type of address: 'P2ST' (default) or 'P2PKH' or 'GROUP'
   * @returns A new valid and frozen instance of an Address
   */
  constructor(data: Address | string | Uint8Array, network?: Networkish, type?: AddressType) {
    ValidationUtils.validateArgument(data != null, 'First argument is required, please include address data.');

    if (data instanceof Address) {
      return data; // Immutable instance
    }

    if (typeof data === 'string') {
      return Address.fromString(data);
    }

    ValidationUtils.validateArgument(BufferUtils.isBuffer(data), "data must be Address, string or Buffer");

    Address.validateParams(network, type);

    this.data = data;
    this.network = networks.get(network) || networks.defaultNetwork;
    this.type = type || AddressType.PayToScriptTemplate;
  }

  public static validateParams(network?: Networkish, type?: AddressType): void {
    if (network && !networks.get(network)) {
      throw new TypeError('Second argument must be "mainnet" or "testnet".');
    }
  
    if (type && (type !== AddressType.PayToScriptTemplate && type !== AddressType.GroupIdAddress && type !== AddressType.PayToPublicKeyHash)) {
      throw new TypeError('Third argument must be "P2ST", "P2PKH" or "GROUP"');
    }
  }

  /**
   * @param address string
   * 
   * @returns A new valid and frozen instance of an Address
   */
  public static fromString(address: string): Address {
    ValidationUtils.validateArgument(typeof address === 'string', 'parameter supplied is not a string.');
    ValidationUtils.validateArgument(address.length > 34, 'Invalid Address string provided');

    let parts = AddressFormatter.decode(address);
    let network = networks.get(parts.prefix, 'prefix');
    return new Address(parts.data, network, parts.type);
  }

  /**
   * Will return a cashaddr representation of the address. Always return lower case
   * Can be converted by the caller to uppercase is needed (still valid).
   *
   * @returns Nexa address
   */
  public toString(): string {
    return AddressFormatter.encode(this.network.prefix, this.type, this.data);
  }

  /**
   * Will return a string formatted for the console
   *
   * @returns {string} Bitcoin address
   */
  public inspect(): string {
    return `<Address: ${this}, type: ${this.type}, network: ${this.network}>`;
  }

  /**
   * Instantiate an address from an Object
   *
   * @param obj - A JSON object with keys: data, network and type
   * @returns A new valid instance of an Address
   */
  public static fromObject(obj: AddressDto): Address {
    ValidationUtils.validateArgument(typeof obj?.data === 'string', 'Must provide a `data` property');
    ValidationUtils.validateArgument(typeof obj?.type === 'string', 'Must provide a `type` property');
    ValidationUtils.validateState(CommonUtils.isHexa(obj.data), `Unexpected data property, expected to be hex.`);

    let hashBuffer = BufferUtils.hexToBuffer(obj.data);
    return new Address(hashBuffer, obj.network, obj.type as AddressType);
  }

  /**
   * @returns A plain object with the address information
   */
  public toJSON(): AddressDto {
    return {
      data: BufferUtils.bufferToHex(this.data),
      type: this.type,
      network: this.network.toString()
    };
  }

  public toObject = this.toJSON;

  /**
   * @return true if an address is of pay to public key hash type
   */
  public isPayToPublicKeyHash(): boolean {
    return this.type === AddressType.PayToPublicKeyHash;
  }

  /**
   * @return true if an address is of pay to script template type
   */
  public isPayToScriptTemplate(): boolean {
    return this.type === AddressType.PayToScriptTemplate;
  }

  /**
   * @return true if an address is a group token address
   */
  public isGroupIdentifierAddress(): boolean {
    return this.type === AddressType.GroupIdAddress;
  }

  /**
   * Will return a validation error if exists
   *
   * @example
   * ```javascript
   * // a network mismatch error
   * let error = Address.getValidationError('nexatest:nqtsq5g567x44x5g54t2wsxz60zwqmyks63rkrddl4stwnzu', 'testnet');
   * ```
   *
   * @param data The encoded data
   * @param network either a Network instance, 'mainnet', or 'testnet'
   * @param type The type of address: 'P2ST' or 'GROUP' or 'P2PKH'
   * @returns The corresponding error message
   */
  public static getValidationError(data: string | Uint8Array, network?: Networkish, type?: AddressType): Error | undefined {
    try {
      if (typeof data === 'string') {
        let addr = Address.fromString(data);
        data = addr.data;

        if (network && networks.get(network)?.prefix !== addr.network.prefix) {
          throw new Error('Address has mismatched network type.');
        }
        network = addr.network;

        if (type && type !== addr.type) {
          throw new Error('Address has mismatched type.');
        }
        type = addr.type;
      }
      this.validateParams(network, type);
      AddressFormatter.encode(networks.get(network)?.prefix || networks.defaultNetwork.prefix, type || AddressType.PayToScriptTemplate, data);
    } catch (e) {
      return e as Error;
    }
  }

  /**
   * Will return a boolean if an address is valid
   *
   * @example
   * ```javascript
   * assert(Address.isValid('nexa:nqtsq5g567x44x5g54t2wsxz60zwqmyks63rkrddsfq94pd2', 'mainned'));
   * ```
   *
   * @param data The encoded data
   * @param network either a Network instance, 'mainnet', or 'testnet'
   * @param type The type of address: 'P2ST' or 'GROUP' or 'P2PKH'
   * @returns true if valid
   */
  public static isValid(data: string | Uint8Array, network?: Networkish, type?: AddressType): boolean {
    return !Address.getValidationError(data, network, type);
  }

  /**
   * Instantiate an address from a PublicKey instance
   *
   * @param pubkey the public key instance
   * @param network either a Network instance, 'mainnet' or 'testnet'
   * @param type address encoding type
   * @returns A new valid and frozen instance of an Address
   */
  public static fromPublicKey(pubkey: PublicKey, network?: Networkish, type?: AddressType): Address {
    let data = this._transformPublicKey(pubkey, type);
    return new Address(data, network, type);
  }

  /**
   * Instantiate an address from a non grouped script template
   *
   * @param templateHash An instance of a template hash Uint8Array
   * @param constraintHash An instance of a constraint hash Uint8Array
   * @param visibleArgs An array of push-only args, or hex string represent script buffer, or Script with push args
   * @param network either a Network instance, 'mainnet' or 'testnet'
   * @returns A new valid and frozen instance of an Address
   */
  public static fromScriptTemplate(templateHash: Uint8Array | Opcode, constraintHash: Uint8Array | Opcode, visibleArgs?: string | Script | ScriptElement[], network?: Networkish): Address {
    let data = Address._transformScriptTemplate(templateHash, constraintHash, visibleArgs);
    return new Address(data, network, AddressType.PayToScriptTemplate);
  }

  /**
   * Internal function to transform a {@link PublicKey}
   */
  private static _transformPublicKey(pubkey: PublicKey, type = AddressType.PayToScriptTemplate): Uint8Array {
    ValidationUtils.validateArgument(pubkey instanceof PublicKey, 'Address must be an instance of PublicKey.')

    if (type === AddressType.PayToPublicKeyHash) {
      return Hash.sha256ripemd160(pubkey.toBuffer());
    } else if (type === AddressType.PayToScriptTemplate) {
      let constraint = Script.empty().add(pubkey.toBuffer());
      let constraintHash = Hash.sha256ripemd160(constraint.toBuffer());
      return Address._transformScriptTemplate(Opcode.OP_1, constraintHash);
    } else {
      throw new Error("type must be P2ST or P2PKH");
    }
  }

  /**
   * Internal function to transform a Script Template params
   */
  private static _transformScriptTemplate(templateHash: Uint8Array | Opcode, constraintHash: Uint8Array | Opcode, visibleArgs?: string | Script | ScriptElement[]): Uint8Array {
    if (templateHash != Opcode.OP_1 && !BufferUtils.isHashBuffer(templateHash)) {
      throw new TypeError('templateHash supplied is not a hash buffer or well-known OP_1.');
    }
    if (constraintHash != Opcode.OP_FALSE && !BufferUtils.isHashBuffer(constraintHash)) {
      throw new TypeError('constraintHash supplied is not a hash buffer or OP_FALSE.');
    }

    let scriptTemplate = Script.empty().add(Opcode.OP_FALSE).add(templateHash).add(constraintHash);
    if (visibleArgs) {
      if (Array.isArray(visibleArgs)) {
        visibleArgs.forEach(arg => scriptTemplate.add(arg));
      } else if (visibleArgs instanceof Script) {
        scriptTemplate.add(visibleArgs);
      } else if (CommonUtils.isHexa(visibleArgs)) {
        scriptTemplate.add(Script.fromHex(visibleArgs));
      }
    }

    return new BufferWriter().writeVarLengthBuf(scriptTemplate.toBuffer()).toBuffer();
  }

  /**
   * Will return the transaction output type by the address type
   * 
   * @param address as string
   * @returns 1 - Template, 0 - otherwise
   */
  public static getOutputType(address: string): number {
    return this.fromString(address).getOutputType();
  }

  /**
   * Will return the transaction output type by the address type
   * 
   * @returns 1 - Template, 0 - otherwise
   */
  public getOutputType(): number {
    return this.isPayToScriptTemplate() ? 1 : 0;
  }
}