import BN from 'bn.js';
import Ens from './Ens';
import Zns from './Zns';
import Cns from './Cns';
import UdApi from './UdApi';
import {
  Blockchain,
  UnclaimedDomainResponse,
  ResolutionResponse,
  DefaultAPI,
  API,
  NamingServiceName,
  Web3Version0Provider,
  Web3Version1Provider,
  Provider,
  NamingServiceSource,
  SourceDefinition,
  NamehashOptions,
  NamehashOptionsDefault,
  DnsRecordType,
  DnsRecord,
  CryptoRecords,
} from './publicTypes';
import { nodeHash } from './types';
import { EthersProvider } from './publicTypes';
import ResolutionError, { ResolutionErrorCode } from './errors/resolutionError';
import NamingService from './NamingService';
import { signedInfuraLink } from './utils';
import { Eip1993Factories } from './utils/Eip1993Factories';
import DnsUtils from './DnsUtils';

/**
 * Blockchain domain Resolution library - Resolution.
 * @example
 * ```
 * import Resolution from '@unstoppabledomains/resolution';
 *
 * let resolution = new Resolution({ blockchain: {
 *        ens: {
 *           url: "https://mainnet.infura.io/v3/12351245223",
 *           network: "mainnet"
 *        }
 *      }
 *   });
 *
 * let domain = "brad.zil";
 * resolution.addr(domain, "eth").then(addr => console.log(addr));;
 * ```
 */
export default class Resolution {
  /** @internal */
  readonly blockchain: boolean;
  /** @internal */
  readonly ens?: Ens;
  /** @internal */
  readonly zns?: Zns;
  /** @internal */
  readonly cns?: Cns;
  /** @internal */
  readonly api?: UdApi;

  constructor({
    blockchain = true,
    api = DefaultAPI,
  }: { blockchain?: Blockchain | boolean; api?: API } = {}) {
    this.blockchain = !!blockchain;
    if (blockchain) {
      if (blockchain === true) {
        blockchain = {};
      }

      const web3provider = blockchain.web3Provider;
      if (web3provider) {
        console.warn(
          'Usage of `web3Provider` option is deprecated. Use `provider` option instead for each individual blockchain',
        );
      }

      const ens = this.normalizeSource(blockchain.ens, web3provider);
      const zns = this.normalizeSource(blockchain.zns);
      const cns = this.normalizeSource(blockchain.cns, web3provider);

      if (ens) {
        this.ens = new Ens(ens);
      }

      if (zns) {
        this.zns = new Zns(zns);
      }

      if (cns) {
        this.cns = new Cns(cns);
      }

    } else {
      this.api = new UdApi(api);
    }

  }

  /**
   * Creates a resolution with configured infura id for ens and cns
   * @param infura infura project id
   * @param network ethereum network name
   */
  static infura(infura: string, network = 'mainnet'): Resolution {
    return new this({
      blockchain: {
        ens: { url: signedInfuraLink(infura, network), network },
        cns: { url: signedInfuraLink(infura, network), network },
      },
    });
  }

  /**
   * Creates a resolution instance with configured provider
   * @param provider - any provider compatible with EIP-1193
   * @see https://eips.ethereum.org/EIPS/eip-1193
   */
  static fromEip1193Provider(provider: Provider): Resolution {
    return new this({
      blockchain: { zns: true, ens: { provider }, cns: { provider } },
    });
  }

  /**
   * Create a resolution instance from web3 0.x version provider
   * @param provider - an 0.x version provider from web3 ( must implement sendAsync(payload, callback) )
   * @see https://github.com/ethereum/web3.js/blob/0.20.7/lib/web3/httpprovider.js#L116
   */
  static fromWeb3Version0Provider(provider: Web3Version0Provider): Resolution {
    return this.fromEip1193Provider(
      Eip1993Factories.fromWeb3Version0Provider(provider),
    );
  }

  /**
   * Create a resolution instance from web3 1.x version provider
   * @param provider - an 1.x version provider from web3 ( must implement send(payload, callback) )
   * @see https://github.com/ethereum/web3.js/blob/1.x/packages/web3-core-helpers/types/index.d.ts#L165
   * @see https://github.com/ethereum/web3.js/blob/1.x/packages/web3-providers-http/src/index.js#L95
   */
  static fromWeb3Version1Provider(provider: Web3Version1Provider): Resolution {
    return this.fromEip1193Provider(
      Eip1993Factories.fromWeb3Version1Provider(provider),
    );
  }

  /**
   * Creates instance of resolution from provider that implements Ethers Provider#call interface.
   * This wrapper support only `eth_call` method for now, which is enough for all the current Resolution functionality
   * @param provider - provider object
   * @see https://github.com/ethers-io/ethers.js/blob/v4-legacy/providers/abstract-provider.d.ts#L91
   * @see https://github.com/ethers-io/ethers.js/blob/v5.0.4/packages/abstract-provider/src.ts/index.ts#L224
   * @see https://docs.ethers.io/ethers.js/v5-beta/api-providers.html#jsonrpcprovider-inherits-from-provider
   * @see https://github.com/ethers-io/ethers.js/blob/master/packages/providers/src.ts/json-rpc-provider.ts
   */
  static fromEthersProvider(provider: EthersProvider): Resolution {
    return this.fromEip1193Provider(
      Eip1993Factories.fromEthersProvider(provider),
    );
  }

  /**
   * Resolves the given domain
   * @async
   * @param domain - domain name to be resolved
   * @returns A promise that resolves in an object
   */
  async resolve(domain: string): Promise<ResolutionResponse> {
    domain = this.prepareDomain(domain);
    const method = this.getNamingMethodOrThrow(domain);
    const result = await method.resolve(domain);
    return result || UnclaimedDomainResponse;
  }

  /**
   * Resolves given domain name to a specific currency address if exists
   * @async
   * @param domain - domain name to be resolved
   * @param currencyTicker - currency ticker like BTC, ETH, ZIL
   * @deprecated since Resolution v1.7.0
   * @returns A promise that resolves in an address or null
   */
  async address(
    domain: string,
    currencyTicker: string,
  ): Promise<string | null> {
    console.warn(
      'Resolution#address is deprecated since v1.7.0, use Resolution#addr instead',
    );
    domain = this.prepareDomain(domain);
    try {
      return await this.addressOrThrow(domain, currencyTicker);
    } catch (error) {
      if (error instanceof ResolutionError) {
        return null;
      } else {
        throw error;
      }

    }
  }

  /**
   * Resolves given domain name to a specific currency address if exists
   * @async
   * @param domain - domain name to be resolved
   * @param currencyTicker - currency ticker like BTC, ETH, ZIL
   * @throws [[ResolutionError]] if address is not found
   * @returns A promise that resolves in an address
   */
  async addr(domain: string, currrencyTicker: string): Promise<string> {
    return await this.record(
      domain,
      `crypto.${currrencyTicker.toUpperCase()}.address`,
    );
  }

  /**
   * Resolves given domain name to a verified twitter handle
   * @async
   * @param domain - domain name to be resolved
   * @throws [[ResolutionError]] if twitter is not found
   * @returns A promise that resolves in a verified twitter handle
   */
  async twitter(domain: string): Promise<string> {
    domain = this.prepareDomain(domain);
    const namingService = this.serviceName(domain);
    if (namingService !== 'CNS') {
      throw new ResolutionError(ResolutionErrorCode.UnsupportedMethod, {
        domain,
        methodName: 'twitter',
      });
    }

    const method = this.getNamingMethodOrThrow(domain);
    return method.twitter(domain);
  }

  /**
   * Resolve a chat id from the domain record
   * @param domain - domain name to be resolved
   * @throws [[ResolutionError]]
   * @returns A promise that resolves in chatId
   */
  async chatId(domain: string): Promise<string> {
    return await this.record(domain, 'gundb.username.value');
  }

  /**
   * Resolve a gundb public key from the domain record
   * @param domain - domain name to be resolved
   * @throws [[ResolutionError]]
   * @returns a promise that resolves in gundb public key
   */
  async chatPk(domain: string): Promise<string> {
    return await this.record(domain, 'gundb.public_key.value');
  }

  /**
   * Resolves the IPFS hash configured for domain records on ZNS
   * @param domain - domain name
   * @throws [[ResolutionError]]
   */
  async ipfsHash(domain: string): Promise<string> {
    domain = this.prepareDomain(domain);
    return await this.getPreferableNewRecord(domain, 'dweb.ipfs.hash', 'ipfs.html.value');
  }

  /**
   * Resolves the httpUrl attached to domain
   * @param domain - domain name
   */
  async httpUrl(domain: string): Promise<string> {
    domain = this.prepareDomain(domain);
    return await this.getPreferableNewRecord(domain, 'browser.redirect_url', 'ipfs.redirect_domain.value');
  }

  /**
   * Resolves the ipfs redirect url for a supported domain records
   * @deprecated since v1.0.15 use Resolution#httpUrl instead
   * @param domain - domain name
   * @throws [[ResolutionError]]
   * @returns A Promise that resolves in redirect url
   */
  async ipfsRedirect(domain: string): Promise<string> {
    console.warn(
      'Resolution#ipfsRedirect is deprecated since v1.0.15, use Resolution#httpUrl instead',
    );
    return await this.record(domain, 'ipfs.redirect_domain.value');
  }

  /**
   * Resolves the ipfs email field from whois configurations
   * @param domain - domain name
   * @throws [[ResolutionError]]
   * @returns A Promise that resolves in an email address configured for this domain whois
   */
  async email(domain: string): Promise<string> {
    return await this.record(domain, 'whois.email.value');
  }

  /**
   * @returns A specific currency address or throws an error
   * @param domain domain name
   * @param currencyTicker currency ticker such as
   *  - ZIL
   *  - BTC
   *  - ETH
   * @throws [[ResolutionError]] if address is not found
   * @deprecated since v1.7.0 use Resolution#addr instead
   */
  async addressOrThrow(
    domain: string,
    currencyTicker: string,
  ): Promise<string> {
    console.warn(
      'Resolution#addressOrThrow is deprecated since v1.7.0, use Resolution#addr instead',
    );
    domain = this.prepareDomain(domain);
    const method = this.getNamingMethodOrThrow(domain);
    try {
      const addr = await method.record(
        domain,
        `crypto.${currencyTicker.toUpperCase()}.address`,
      );
      return addr;
    } catch (error) {
      // re-throw an error for back compatability. old method throws deprecated UnspecifiedCurrency code since before v1.7.0
      if (
        error instanceof ResolutionError &&
        error.code === ResolutionErrorCode.RecordNotFound
      ) {
        throw new ResolutionError(ResolutionErrorCode.UnspecifiedCurrency, {
          domain,
          currencyTicker,
        });
      }

      throw error;
    }
  }

  /**
   * @returns the resolver address for a specific domain
   * @param domain - domain to look for
   */
  async resolver(domain: string): Promise<string> {
    domain = this.prepareDomain(domain);
    const resolver = await this.getNamingMethodOrThrow(domain).resolver(domain);
    if (!resolver) {
      throw new ResolutionError(ResolutionErrorCode.UnspecifiedResolver, {domain});
    }
    return resolver;
  }

  /**
   * @param domain - domain name
   * @returns An owner address of the domain
   */
  async owner(domain: string): Promise<string | null> {
    domain = this.prepareDomain(domain);
    const method = this.getNamingMethodOrThrow(domain);
    return (await method.owner(domain)) || null;
  }

  /**
   * @param domain - domain name
   * @param recordKey - a name of a record to be resolved
   * @returns A record value promise for a given record name
   */
  async record(domain: string, recordKey: string): Promise<string> {
    domain = this.prepareDomain(domain);
    const method = this.getNamingMethodOrThrow(domain);
    return await method.record(domain, recordKey);
  }

  /**
   * @param domain domain name
   * @param keys Array of record keys to be resolved
   * @returns A Promise with key-value mapping of domain records
   */
  async records(domain: string, keys: string[]): Promise<CryptoRecords> {
    domain = this.prepareDomain(domain);
    const method = this.getNamingMethodOrThrow(domain);
    return await method.records(domain, keys);
  }

  /**
   * This method is only for ens at the moment. Reverse the ens address to a ens registered domain name
   * @async
   * @param address - address you wish to reverse
   * @param currencyTicker - currency ticker like BTC, ETH, ZIL
   * @returns Domain name attached to this address
   */
  async reverse(
    address: string,
    currencyTicker: string,
  ): Promise<string | null> {
    return (this.findNamingService(NamingServiceName.ENS) as Ens).reverse(
      address,
      currencyTicker,
    );
  }

  /**
   * @returns Produces a namehash from supported naming service in hex format with 0x prefix.
   * Corresponds to ERC721 token id in case of Ethereum based naming service like ENS or CNS.
   * @param domain domain name to be converted
   * @param options formatting options
   * @throws [[ResolutionError]] with UnsupportedDomain error code if domain extension is unknown
   */
  namehash(domain: string, options: NamehashOptions = NamehashOptionsDefault): string {
    domain = this.prepareDomain(domain);
    return this.formatNamehash(this.getNamingMethodOrThrow(domain).namehash(domain), options);
  }

  /**
   * @returns a namehash of a subdomain with name label
   * @param parent namehash of a parent domain
   * @param label subdomain name
   * @param method "ENS", "CNS" or "ZNS"
   * @param options formatting options
   */
  childhash(
    parent: nodeHash,
    label: string,
    method: NamingServiceName,
    options: NamehashOptions = NamehashOptionsDefault,
  ): nodeHash {
    return this.formatNamehash(this.findNamingService(method).childhash(parent, label), options);
  }

  private formatNamehash(hash, options: NamehashOptions) {
    hash = hash.replace('0x', '');
    if (options.format === 'dec') {
      return new BN(hash, 'hex').toString(10);
    } else {
      return options.prefix ? '0x' + hash : hash;
    }

  }

  /**
   * Checks weather the domain name matches the hash
   * @param domain - domain name to check againt
   * @param hash - hash obtained from the blockchain
   */
  isValidHash(domain: string, hash: string): boolean {
    domain = this.prepareDomain(domain);
    return this.namehash(domain) === hash;
  }

  /**
   * Checks if the domain name is valid according to naming service rules
   * for valid domain names.
   * Example: ENS doesn't allow domains that start from '-' symbol.
   * @param domain - domain name to be checked
   */
  isSupportedDomain(domain: string): boolean {
    domain = this.prepareDomain(domain);
    return !!this.getNamingMethod(domain);
  }

  /**
   * Checks if the domain is supported by the specified network as well as if it is in valid format
   * @param domain - domain name to be checked
   */
  isSupportedDomainInNetwork(domain: string): boolean {
    domain = this.prepareDomain(domain);
    const method = this.getNamingMethod(domain);
    return !!method && method.isSupportedNetwork();
  }

  /**
   * Returns the name of the service for a domain ENS | CNS | ZNS
   * @param domain - domain name to look for
   */
  serviceName(domain: string): NamingServiceName {
    domain = this.prepareDomain(domain);
    return this.getNamingMethodOrThrow(domain).serviceName(domain);
  }

  /**
   * Returns all record keys of the domain.
   * This method is strongly unrecommended for production use due to lack of support for many ethereum service providers and low performance
   * Method is not supported by ENS
   * @param domain - domain name
   */
  async allRecords(domain: string): Promise<CryptoRecords> {
    domain = this.prepareDomain(domain);
    return await this.getNamingMethodOrThrow(domain).allRecords(domain);
  }

  async dns(domain: string, types: DnsRecordType[]): Promise<DnsRecord[]> {
    const dnsUtils = new DnsUtils();
    domain = this.prepareDomain(domain);
    const method = this.getNamingMethodOrThrow(domain);
    const dnsRecordKeys = this.getDnsRecordKeys(types);
    const blockchainData = await method.records(domain, dnsRecordKeys);
    return dnsUtils.toList(blockchainData);
  }

  private getDnsRecordKeys(types: DnsRecordType[]): string[] {
    const records = ['dns.ttl'];
    types.forEach(type => {
      records.push(`dns.${type}`);
      records.push(`dns.${type}.ttl`);
    });
    return records;
  }

  private async getPreferableNewRecord(domain: string, newRecord: string, oldRecord: string): Promise<string> {
    const records = await this.records(domain, [newRecord, oldRecord]) as Record<string, string>;
    return NamingService.ensureRecordPresence(domain, newRecord, records[newRecord] || records[oldRecord]);
  }

  private getNamingMethod(domain: string): NamingService | undefined {
    return this.getResolutionMethods().find(method =>
      method.isSupportedDomain(domain),
    );
  }

  private getResolutionMethods(): NamingService[] {
    return (this.blockchain
      ? ([this.ens, this.zns, this.cns] as NamingService[])
      : ([this.api] as NamingService[])
    ).filter(v => v);
  }

  private getNamingMethodOrThrow(domain: string): NamingService {
    const method = this.getNamingMethod(domain);
    if (!method) {
      throw new ResolutionError(ResolutionErrorCode.UnsupportedDomain, {
        domain,
      });
    }

    return method;
  }

  private findNamingService(name: NamingServiceName): NamingService {
    const service = this.getResolutionMethods().find(m => m.name === name);
    if (!service) {
      throw new ResolutionError(ResolutionErrorCode.NamingServiceDown, {
        method: name,
      });
    }

    return service;
  }

  private prepareDomain(domain: string): string {
    return domain ? domain.trim().toLowerCase() : '';
  }

  private normalizeSource(
    source: NamingServiceSource | undefined,
    provider?: Provider,
  ): SourceDefinition | false {
    switch (typeof source) {
    case 'undefined': {
      return { provider };
    }
    case 'boolean': {
      return source ? { provider } : false;
    }
    case 'string': {
      return { url: source };
    }
    case 'object': {
      return { provider, ...source };
    }
    }
    throw new Error('Unsupported configuration');
  }
}

export { Resolution };
