import {
	Unit,
	convert,
	createBlock,
	deriveAddress,
	derivePublicKey,
	validateWork,
} from 'nanocurrency';
import NanoRPC, { NanoRpcConfig } from '../rpc/RpcController';
import {
	DEFAULT_TIMEOUT,
	MIN_AMOUNT,
	RECEIVE_DIFFICULTY,
	SEND_DIFFICULTY,
} from '@/Constants';
import BaseController from '@/BaseController';
import { TunedBigNumber } from '@/utils';
import Logger from '@/logger/Logger';
import Locker from './Locker';

export interface NanoWalletConfig extends NanoRpcConfig {
	privateKey: string;
	representative: string;
	minAmountRaw?: string;
	precomputeWork?: boolean;
}

export interface ReceivableBlock {
	blockHash: string;
	amount: string;
}

interface Work {
	hash: string;
	threshold: string;
	work: string;
}

export interface NanoWalletState {
	balance: string;
	receivable: string;
	receivableBlocks: ReceivableBlock[];
	frontier: string | null;
	representative: string | null;
	work: Work | null;
}

export default class NanoWallet extends BaseController<
	NanoWalletConfig,
	NanoWalletState
> {
	rpc: NanoRPC;
	publicKey: string;
	account: string;

	defaultConfig = {
		rpcUrls: [],
		workerUrls: [],
		privateKey: null,
		representative: '',
		precomputeWork: true,
		minAmountRaw: convert(MIN_AMOUNT.toString(), {
			from: Unit.NANO,
			to: Unit.raw,
		}),
		timeout: DEFAULT_TIMEOUT,
		debug: false,
	} as any as NanoWalletConfig;

	defaultState: NanoWalletState = {
		balance: '0',
		receivable: '0',
		receivableBlocks: [],
		frontier: null,
		representative: null,
		work: null,
	};

	logger: Logger;

	locker = new Locker();

	constructor(config: NanoWalletConfig, state?: NanoWalletState | null) {
		super(config, state || undefined);
		this.publicKey = derivePublicKey(config.privateKey);
		this.account = deriveAddress(this.publicKey, { useNanoPrefix: true });
		this.logger = new Logger('NANO_WALLET', config.debug);
		this.logger.info(`Imported account: ${this.account}`);
		this.initialize();
		this.rpc = new NanoRPC({
			rpcUrls: this.config.rpcUrls,
			workerUrls: this.config.workerUrls,
			timeout: this.config.timeout,
			debug: this.config.debug,
		});
		let previousFrontier = this.state.frontier;
		if (this.config.precomputeWork) {
			this.subscribe(state => {
				if (state.work && state.work.hash !== state.frontier) {
					this.update({ work: null });
				}
				if (
					state.frontier &&
					state.frontier !== previousFrontier &&
					(!state.work || state.work.hash !== state.frontier)
				) {
					this.logger.info('Precomputing Work for', state.frontier);
					this.getWork(state.frontier, SEND_DIFFICULTY);
				}
				previousFrontier = state.frontier;
			});
		}
	}

	async sync() {
		try {
			const { balance, frontier, receivable, representative } =
				await this.rpc.accountInfo(this.account);
			this.logger.info(
				`Wallet Sync! Balance: ${convert(balance, {
					from: Unit.raw,
					to: Unit.NANO,
				})} NANO. Receivable: ${convert(receivable, {
					from: Unit.raw,
					to: Unit.NANO,
				})}`,
			);
			this.update({ balance, frontier, receivable, representative });
		} catch (error: any) {
			if (error.message !== 'Account not found') {
				this.logger.error(
					'sync:',
					error instanceof Error ? error.message : error,
				);
				throw error;
			}
		}
		await this.getReceivable();
	}

	private async workGenerate(hash: string, threshold: string) {
		try {
			const startedAt = Date.now();

			const { work } = await this.rpc.workGenerate(hash, threshold);

			if (!work) {
				throw new Error('No work');
			}

			const isValidWork = validateWork({
				work,
				blockHash: hash,
				threshold,
			});

			if (!isValidWork) {
				throw new Error('Invalid work');
			}

			this.logger.info(
				`Generated Work for ${hash} in ${Date.now() - startedAt} ms`,
			);

			return work;
		} catch (error) {
			this.logger.error(
				'workGenerate:',
				error instanceof Error ? error.message : error,
			);
			throw error;
		}
	}

	private async getWork(hash: string, threshold: string) {
		if (
			this.state.work?.hash === hash &&
			parseInt(this.state.work.threshold, 16) >= parseInt(threshold, 16)
		) {
			this.logger.info(`Using precomputed Work for ${hash}`);
			return this.state.work.work;
		}
		const work = await this.workGenerate(hash, threshold);

		// TODO: Store the generated threshold, instead the requested one

		this.update({
			work: {
				hash,
				threshold,
				work,
			},
		});
		return work;
	}

	async getReceivable() {
		try {
			const { blocks = {} } = await this.rpc.receivable(this.account, {
				threshold: this.config.minAmountRaw,
			});
			let receivableBlocks: ReceivableBlock[] = [];
			let receivable = '0';
			for (const blockHash in blocks) {
				receivableBlocks.push({
					blockHash,
					amount: blocks[blockHash],
				});
				receivable = TunedBigNumber(receivable)
					.plus(blocks[blockHash])
					.toString();
			}
			this.logger.info(
				`${convert(receivable, {
					from: Unit.raw,
					to: Unit.NANO,
				})} NANO to receive from ${receivableBlocks.length} blocks`,
			);
			this.update({ receivableBlocks, receivable });
			return { receivableBlocks, receivable };
		} catch (error) {
			this.logger.error(
				'getReceivable',
				error instanceof Error ? error.message : error,
			);
			throw error;
		}
	}

	async receive(link: string) {
		await this.locker.acquire();
		try {
			link = link.toUpperCase();

			const amount = this.state.receivableBlocks.find(
				({ blockHash }) => blockHash === link,
			)?.amount;

			if (!amount) {
				throw new Error('No receivable block');
			}

			const balance = TunedBigNumber(this.state.balance)
				.plus(amount)
				.toString();

			this.logger.info(
				`Receiving ${convert(amount, {
					from: Unit.raw,
					to: Unit.NANO,
				})} NANO from block ${link}`,
			);

			const { block, hash } = createBlock(this.config.privateKey, {
				previous: this.state.frontier,
				representative: this.config.representative,
				balance,
				link,
				work: null,
			});

			const frontier = this.state.frontier || this.publicKey;

			const work = await this.getWork(frontier, RECEIVE_DIFFICULTY);

			const processed = await this.rpc.process({
				...block,
				work,
			});

			if (processed.hash !== hash) {
				throw new Error('Block hash mismatch');
			}

			const receivableBlocks = this.state.receivableBlocks.filter(
				({ blockHash }) => blockHash !== link,
			);
			const receivable = TunedBigNumber(this.state.receivable)
				.minus(amount)
				.toString();

			this.update({
				balance,
				frontier: hash,
				receivableBlocks,
				receivable,
			});

			return { hash };
		} catch (error) {
			this.logger.error(
				'receive:',
				error instanceof Error ? error.message : error,
			);
			throw error;
		} finally {
			await this.locker.release();
		}
	}

	async send(to: string, amount: string) {
		await this.locker.acquire();
		try {
			if (this.state.frontier === null) {
				throw new Error('No frontier');
			}

			const balance = TunedBigNumber(this.state.balance)
				.minus(amount)
				.toString();

			this.logger.info(
				`Sending ${convert(amount, {
					from: Unit.raw,
					to: Unit.NANO,
				})} NANO to ${to}`,
			);

			const { block, hash } = createBlock(this.config.privateKey, {
				previous: this.state.frontier,
				representative: this.config.representative,
				balance,
				link: to,
				work: null,
			});

			const work = await this.getWork(this.state.frontier, SEND_DIFFICULTY);

			const processed = await this.rpc.process({
				...block,
				work,
			});

			if (processed.hash !== hash) {
				throw new Error('Block hash mismatch');
			}

			this.update({
				balance,
				frontier: hash,
			});

			return { hash };
		} catch (error) {
			this.logger.error(
				'send:',
				error instanceof Error ? error.message : error,
			);
			throw error;
		} finally {
			this.locker.release();
		}
	}

	async sweep(to: string) {
		await this.locker.acquire();
		try {
			if (this.state.frontier === null) {
				throw new Error('No frontier');
			}

			this.logger.info(`Sweeping all funds from ${this.account} to ${to}`);

			const { block, hash } = createBlock(this.config.privateKey, {
				previous: this.state.frontier,
				representative: this.config.representative,
				balance: '0',
				link: to,
				work: null,
			});

			const work = await this.getWork(this.state.frontier, SEND_DIFFICULTY);

			const processed = await this.rpc.process({
				...block,
				work,
			});

			if (processed.hash !== hash) {
				throw new Error('Block hash mismatch');
			}

			this.update({
				balance: '0',
				frontier: hash,
			});

			return { hash };
		} catch (error) {
			this.logger.error(
				'sweep:',
				error instanceof Error ? error.message : error,
			);
			throw error;
		} finally {
			this.locker.release();
		}
	}

	async setRepresentative(account?: string) {
		await this.locker.acquire();
		try {
			if (this.state.frontier === null) {
				throw new Error('No frontier');
			}

			this.logger.info(`Setting representative: ${account}`);

			const representative = account || this.config.representative;

			const { block, hash } = createBlock(this.config.privateKey, {
				previous: this.state.frontier,
				representative,
				balance: this.state.balance,
				link: null,
				work: null,
			});

			const work = await this.getWork(this.state.frontier, SEND_DIFFICULTY);

			const processed = await this.rpc.process({
				...block,
				work,
			});

			if (processed.hash !== hash) {
				throw new Error('Block hash mismatch');
			}

			this.update({
				balance: '0',
				frontier: hash,
				representative,
			});

			this.configure({
				representative,
			});

			return { hash };
		} catch (error) {
			this.logger.error(
				'setRepresentative:',
				error instanceof Error ? error.message : error,
			);
			throw error;
		} finally {
			this.locker.release();
		}
	}

	get balance() {
		return this.state.balance;
	}

	get receivable() {
		return this.state.receivable;
	}

	get receivableBlocks() {
		return this.state.receivableBlocks;
	}

	get frontier() {
		return this.state.frontier;
	}

	// Current representative may be different from the initialized representative
	// if after initialization of instance there has not yet been a new transaction or
	// .setRepresentative() has not been called yet.
	get currentRepresentative() {
		return this.state.representative;
	}
}
