package main

import (
	"flag"
	"fmt"
	"os"
	"regexp"
	"strconv"
	"strings"

	"github.com/btcsuite/btcutil"
	"github.com/logrusorgru/aurora"
	"github.com/muun/libwallet"
	"github.com/muun/libwallet/emergencykit"
	"github.com/muun/recovery/scanner"
)

const version = "2.1.0"

func main() {
	// Pick up command-line arguments:
	flag.Parse()
	args := flag.Args()

	// Ensure correct form:
	if len(args) > 1 {
		printUsage()
		os.Exit(0)
	}

	// Welcome!
	printWelcomeMessage()

	// We're going to need a few things to move forward with the recovery process. Let's make a list
	// so we keep them in mind:
	var recoveryCode string
	var encryptedKeys []*libwallet.EncryptedPrivateKeyInfo
	var destinationAddress btcutil.Address

	// First on our list is the Recovery Code. This is the time to go looking for that piece of paper:
	recoveryCode = readRecoveryCode()

	// Good! Now, on to those keys. We need to read them and decrypt them:
	encryptedKeys, err := readBackupFromInputOrPDF(flag.Arg(0))
	if err != nil {
		exitWithError(err)
	}

	decryptedKeys, err := decryptKeys(encryptedKeys, recoveryCode)
	if err != nil {
		exitWithError(err)
	}

	decryptedKeys[0].Key.Path = "m/1'/1'" // a little adjustment for legacy users.

	// Finally, we need the destination address to sweep the funds:
	destinationAddress = readAddress()

	sayBlock(`
		Starting scan of all possible addresses. This will take a few minutes.
	`)

	transactionID := doRecovery(decryptedKeys, destinationAddress)

	sayBlock(`
		Transaction sent! You can check the status here: https://blockstream.info/tx/%v
		(it will appear in Blockstream after a short delay)

		We appreciate all kinds of feedback. If you have any, send it to {blue contact@muun.com}
	`, transactionID)
}

// doRecovery runs the scan & sweep process, and returns the ID of the broadcasted transaction.
func doRecovery(decryptedKeys []*libwallet.DecryptedPrivateKey, destinationAddress btcutil.Address) string {
	addrGen := NewAddressGenerator(decryptedKeys[0].Key, decryptedKeys[1].Key)
	utxoScanner := scanner.NewScanner()

	addresses := addrGen.Stream()

	sweeper := Sweeper{
		UserKey:      decryptedKeys[0].Key,
		MuunKey:      decryptedKeys[1].Key,
		Birthday:     decryptedKeys[1].Birthday,
		SweepAddress: destinationAddress,
	}

	reports := utxoScanner.Scan(addresses)

	say("► {white Finding servers...}")

	var lastReport *scanner.Report
	for lastReport = range reports {
		printReport(lastReport)
	}

	fmt.Println()
	fmt.Println()

	if lastReport.Err != nil {
		exitWithError(fmt.Errorf("error while scanning addresses: %w", lastReport.Err))
	}

	say("{green ✓ Scan complete}\n")
	utxos := lastReport.UtxosFound

	if len(utxos) == 0 {
		sayBlock("No funds were discovered\n\n")
		return ""
	}

	var total int64
	for _, utxo := range utxos {
		total += utxo.Amount
		say("• {white %d} sats in %s\n", utxo.Amount, utxo.Address.Address())
	}

	say("\n— {white %d} sats total\n", total)

	txOutputAmount, txWeightInBytes, err := sweeper.GetSweepTxAmountAndWeightInBytes(utxos)
	if err != nil {
		exitWithError(err)
	}

	fee := readFee(txOutputAmount, txWeightInBytes)

	// Then we re-build the sweep tx with the actual fee
	sweepTx, err := sweeper.BuildSweepTx(utxos, fee)
	if err != nil {
		exitWithError(err)
	}

	sayBlock("Sending transaction...")

	err = sweeper.BroadcastTx(sweepTx)
	if err != nil {
		exitWithError(err)
	}

	return sweepTx.TxHash().String()
}

func exitWithError(err error) {
	sayBlock(`
		{red Error!}
		The Recovery Tool encountered a problem. Please, try again.

		If the problem persists, contact {blue support@muun.com} and include this:
		
		――― {white error report} ―――
		%v
		――――――――――――――――――――

		We're always there to help.
	`, err)

	os.Exit(1)
}

func printWelcomeMessage() {
	say(`
		{blue Muun Recovery Tool v%s}

		To recover your funds, you will need:
		
		1. {yellow Your Recovery Code}, which you wrote down during your security setup
		2. {yellow Your Emergency Kit PDF}, which you exported from the app
		3. {yellow Your destination bitcoin address}, where all your funds will be sent
		
		If you have any questions, we'll be happy to answer them. Contact us at {blue support@muun.com}
	`, version)
}

func printUsage() {
	fmt.Println("Usage: recovery-tool [optional: path to Emergency Kit PDF]")
}

func printReport(report *scanner.Report) {
	var total int64
	for _, utxo := range report.UtxosFound {
		total += utxo.Amount
	}

	say("\r► {white Scanned addresses}: %d | {white Sats found}: %d", report.ScannedAddresses, total)
}

func readRecoveryCode() string {
	sayBlock(`
		{yellow Enter your Recovery Code}
		(it looks like this: 'ABCD-1234-POW2-R561-P120-JK26-12RW-45TT')
	`)

	var userInput string
	ask(&userInput)

	userInput = strings.TrimSpace(userInput)
	finalRC := strings.ToUpper(userInput)

	if strings.Count(finalRC, "-") != 7 {
		say(`
			Invalid recovery code. Did you add the '-' separator between each 4-characters segment?
			Please, try again
		`)

		return readRecoveryCode()
	}

	if len(finalRC) != 39 {
		say(`
			Your recovery code must have 39 characters
			Please, try again
		`)

		return readRecoveryCode()
	}

	return finalRC
}

func readBackupFromInputOrPDF(optionalPDF string) ([]*libwallet.EncryptedPrivateKeyInfo, error) {
	// Here we have two possible flows, depending on whether the PDF was provided (pick up the
	// encrypted backup automatically) or not (manual input). If we try for the automatic flow and fail,
	// we can fall back to the manual one.

	// Read metadata from the PDF, if given:
	if optionalPDF != "" {
		encryptedKeys, err := readBackupFromPDF(optionalPDF)

		if err == nil {
			return encryptedKeys, nil
		}

		// Hmm. Okay, we'll confess and fall back to manual input.
		say(`
			Couldn't read the PDF automatically: %v
			Please, enter your data manually
		`, err)
	}

	// Ask for manual input, if we have no PDF or couldn't read it:
	encryptedKeys, err := readBackupFromInput()
	if err != nil {
		return nil, err
	}

	return encryptedKeys, nil
}

func readBackupFromInput() ([]*libwallet.EncryptedPrivateKeyInfo, error) {
	firstRawKey := readKey("first encrypted private key")
	secondRawKey := readKey("second encrypted private key")

	decodedKeys, err := decodeKeysFromInput(firstRawKey, secondRawKey)
	if err != nil {
		return nil, err
	}

	return decodedKeys, nil
}

func readBackupFromPDF(path string) ([]*libwallet.EncryptedPrivateKeyInfo, error) {
	reader := &emergencykit.MetadataReader{SrcFile: path}

	metadata, err := reader.ReadMetadata()
	if err != nil {
		return nil, err
	}

	decodedKeys, err := decodeKeysFromMetadata(metadata)
	if err != nil {
		return nil, err
	}

	return decodedKeys, nil
}

func readKey(keyType string) string {
	sayBlock(`
		{yellow Enter your %v}
		(it looks like this: '9xzpc7y6sNtRvh8Fh...')
	`, keyType)

	// NOTE:
	// Users will most likely copy and paste their keys from the Emergency Kit PDF. In this case,
	// input will come suddenly in multiple lines, so a simple scan & retry (let's say 3 lines
	// were pasted)  will attempt to parse a key and fail 2 times in a row, with leftover characters
	// until the user presses enter to fail for a 3rd time.

	// Given the line lengths actually found in our Emergency Kits, we have a simple solution for now:
	// scan a minimum length of characters. Pasing from current versions of the Emergency Kit will
	// only go past a minimum length when the key being entered is complete, in all cases.
	userInput := askMultiline(libwallet.EncodedKeyLengthLegacy)

	if len(userInput) < libwallet.EncodedKeyLengthLegacy {
		// This is obviously invalid. Other problems will be detected later on, during the actual
		// decoding and decryption stage.
		say(`
			The key you entered doesn't look valid
			Please, try again
		`)

		return readKey(keyType)
	}

	return userInput
}

func readAddress() btcutil.Address {
	sayBlock(`
		{yellow Enter your destination bitcoin address}
	`)

	var userInput string
	ask(&userInput)

	userInput = strings.TrimSpace(userInput)

	addr, err := btcutil.DecodeAddress(userInput, &chainParams)
	if err != nil {
		say(`
			This is not a valid bitcoin address
			Please, try again
		`)

		return readAddress()
	}

	return addr
}

func readFee(totalBalance, weight int64) int64 {
	sayBlock(`
		{yellow Enter the fee rate (sats/byte)}
		Your transaction weighs %v bytes. You can get suggestions in https://bitcoinfees.earn.com/#fees
	`, weight)

	var userInput string
	ask(&userInput)

	feeInSatsPerByte, err := strconv.ParseInt(userInput, 10, 64)
	if err != nil || feeInSatsPerByte <= 0 {
		say(`
			The fee must be a whole number
			Please, try again
		`)

		return readFee(totalBalance, weight)
	}

	totalFee := feeInSatsPerByte * weight

	if totalBalance-totalFee < 546 {
		say(`
			The fee is too high. The remaining amount after deducting is too low to send.
			Please, try again
		`)

		return readFee(totalBalance, weight)
	}

	return totalFee
}

func readConfirmation(value, fee int64, address string) {
	sayBlock(`
		{whiteUnderline Summary}
		  {white Amount}: %v sats
		  {white Fee}: %v sats
		  {white Destination}: %v

		{yellow Confirm?} (y/n)
	`, value, fee, address)

	var userInput string
	ask(&userInput)

	if userInput == "y" || userInput == "Y" {
		return
	}

	if userInput == "n" || userInput == "N" {
		sayBlock(`
			Recovery tool stopped
			You can try again or contact us at {blue support@muun.com}
		`)
		os.Exit(1)
	}

	say(`You can only enter 'y' to confirm or 'n' to cancel`)

	fmt.Print("\n\n")
	readConfirmation(value, fee, address)
}

var leadingIndentRe = regexp.MustCompile("^[ \t]+")
var colorRe = regexp.MustCompile(`\{(\w+?) ([^\}]+?)\}`)

func say(message string, v ...interface{}) {
	noEmptyLine := strings.TrimLeft(message, " \n")
	firstIndent := leadingIndentRe.FindString(noEmptyLine)

	noIndent := strings.ReplaceAll(noEmptyLine, firstIndent, "")

	noTrailingSpace := strings.TrimRight(noIndent, " \t")

	withColors := colorRe.ReplaceAllStringFunc(noTrailingSpace, func(match string) string {
		groups := colorRe.FindStringSubmatch(match)
		return applyColor(groups[1], groups[2])
	})

	fmt.Printf(withColors, v...)
}

func sayBlock(message string, v ...interface{}) {
	fmt.Println()
	say(message, v...)
}

func applyColor(colorName string, text string) string {
	boldText := aurora.Bold(text) // in most terminals, bold colors are prettier and highlight better

	switch colorName {
	case "red":
		return aurora.Red(boldText).String()
	case "blue":
		return aurora.Blue(boldText).String()
	case "yellow":
		return aurora.Yellow(boldText).String()
	case "green":
		return aurora.Green(boldText).String()
	case "white":
		return aurora.White(boldText).String()
	case "whiteUnderline":
		return aurora.Underline(boldText).White().String()
	}

	panic("No such color: " + colorName)
}

func askMultiline(minChars int) string {
	fmt.Print("➜ ")

	var result strings.Builder

	for result.Len() < minChars {
		var line string
		fmt.Scan(&line)

		result.WriteString(strings.TrimSpace(line))
	}

	return result.String()
}

func ask(result *string) {
	fmt.Print("➜ ")
	fmt.Scan(result)
}
