// Copyright (c) 2022-2024 The Decred developers
// Use of this source code is governed by an ISC
// license that can be found in the LICENSE file.

package main

import (
	"context"
	"encoding/binary"
	"encoding/hex"
	"errors"
	"os"
	"sort"

	"github.com/decred/slog"
	"github.com/jessevdk/go-flags"

	"github.com/decred/vspd/database"
	"github.com/decred/vspd/internal/config"
	"github.com/decred/vspd/internal/signal"
)

const (
	logPath = "./vote-validator.log"

	// Send tx hashes to dcrdata in "chunks" to avoid hitting request size limits.
	chunkSize = 2000
)

var cfg struct {
	Testnet      bool   `short:"t" long:"testnet" description:"Run testnet instead of mainnet"`
	ToCheck      int    `short:"n" long:"tickets_to_check" required:"true" description:"Validate votes of the n most recently voted tickets"`
	DatabaseFile string `short:"f" long:"database_file" required:"true" description:"Full path of database file"`
}

type votedTicket struct {
	// From vspd db.
	ticket database.Ticket
	// From dcrdata.
	voteHeight  uint32
	voteVersion uint32
	vote        map[string]string
}

func main() {
	// Run until an exit code is returned.
	os.Exit(run())
}

func run() int {
	// If command line options are requesting help, write it to stdout and exit.
	if config.WriteHelp(&cfg) {
		return 0
	}

	// Parse command line options.
	_, err := flags.Parse(&cfg)
	if err != nil {
		return 1
	}

	var network *config.Network
	if cfg.Testnet {
		network = &config.TestNet3
	} else {
		network = &config.MainNet
	}

	dcrdata := &dcrdataClient{URL: network.BlockExplorerURL}

	// Get the latest vote version. Any votes which don't match this version
	// will be ignored.
	latestVoteVersion := network.CurrentVoteVersion()

	// Open database.
	log := slog.NewBackend(os.Stdout).Logger("")
	vdb, err := database.Open(cfg.DatabaseFile, log, 999)
	if err != nil {
		log.Error(err)
		return 1
	}

	ctx := signal.ShutdownListener(log)

	// Get all voted tickets from database.
	dbTickets, err := vdb.GetVotedTickets()
	if err != nil {
		log.Error(err)
		return 1
	}

	numTickets := len(dbTickets)
	log.Infof("Database has %d voted tickets", numTickets)

	// A bit of pre-processing for later:
	//   - Store the tickets in a map using their hash as the key. This makes
	//     it easier to reference them later.
	//   - Create an array of all hashes. This can easily be sliced into
	//     "chunks" and sent to dcrdata.

	ticketMap := make(map[string]*votedTicket, numTickets)
	ticketHashes := make([]string, 0)
	for _, t := range dbTickets {
		ticketMap[t.Hash] = &votedTicket{ticket: t}
		ticketHashes = append(ticketHashes, t.Hash)
	}

	// Use dcrdata to get spender info for voted tickets (dcrd can't do this).
	log.Infof("Getting vote info from %s", dcrdata.URL)
	for i := 0; i < numTickets; i += chunkSize {
		// Stop if shutdown requested.
		if ctx.Err() != nil {
			return 0
		}

		end := i + chunkSize
		if end > numTickets {
			end = numTickets
		}

		// Get the tx info for each ticket.
		ticketTxns, err := dcrdata.txns(ctx, ticketHashes[i:end], true)
		if err != nil {
			if errors.Is(err, context.Canceled) {
				return 0
			}
			log.Error(err)
			return 1
		}

		spenderHashes := make([]string, 0)
		mapSpenderToTicket := make(map[string]string, len(ticketTxns))
		for _, txn := range ticketTxns {
			spenderHash := txn.Vout[0].Spend.Hash
			spenderHashes = append(spenderHashes, spenderHash)
			mapSpenderToTicket[spenderHash] = txn.TxID
		}

		spenderTxns, err := dcrdata.txns(ctx, spenderHashes, false)
		if err != nil {
			if errors.Is(err, context.Canceled) {
				return 0
			}
			log.Error(err)
			return 1
		}

		for _, tx := range spenderTxns {
			ticketHash := mapSpenderToTicket[tx.TxID]

			// Extract vote height from vOut[0]
			vOut0Script, err := hex.DecodeString(tx.Vout[0].ScriptPubKeyDecoded.Hex)
			if err != nil {
				log.Error(err)
				return 1
			}
			// dcrd/blockchain/stake/staketx.go - SSGenBlockVotedOn()
			votedHeight := binary.LittleEndian.Uint32(vOut0Script[34:38])

			// Extract vote version and votebits from vOut[1]
			vOut1Script, err := hex.DecodeString(tx.Vout[1].ScriptPubKeyDecoded.Hex)
			if err != nil {
				log.Error(err)
				return 1
			}
			// dcrd/blockchain/stake/staketx.go - SSGenVersion()
			voteVersion := binary.LittleEndian.Uint32(vOut1Script[4:8])

			// dcrd/blockchain/stake/staketx.go - SSGenVoteBits()
			votebits := binary.LittleEndian.Uint16(vOut1Script[2:4])

			// Get the recorded on-chain votes for this ticket.
			actualVote := make(map[string]string)
			agendas := network.Deployments[latestVoteVersion]
			for _, agenda := range agendas {
				for _, choice := range agenda.Vote.Choices {
					if votebits&agenda.Vote.Mask == choice.Bits {
						actualVote[agenda.Vote.Id] = choice.Id
					}
				}
			}

			ticketMap[ticketHash].voteHeight = votedHeight
			ticketMap[ticketHash].voteVersion = voteVersion
			ticketMap[ticketHash].vote = actualVote
		}

		log.Infof(" %6d of %d (%0.2f%%)", end, numTickets, float32(end)/float32(numTickets)*100)
	}

	// Convert ticketMap into a slice so that it can be sorted.
	sorted := make([]*votedTicket, 0)
	for _, ticket := range ticketMap {
		sorted = append(sorted, ticket)
	}

	// Sort tickets by vote height
	sort.Slice(sorted, func(i, j int) bool {
		return sorted[i].voteHeight > sorted[j].voteHeight
	})

	// Do the checks.
	results := &results{
		badVotes:      make([]*votedTicket, 0),
		noPreferences: make([]*votedTicket, 0),
		wrongVersion:  make([]*votedTicket, 0),
	}

	for _, t := range sorted[0:cfg.ToCheck] {
		// Stop if shutdown requested.
		if ctx.Err() != nil {
			return 0
		}

		if t.voteVersion != latestVoteVersion {
			results.wrongVersion = append(results.wrongVersion, t)
			continue
		}

		// Get the vote preferences requested by the user.
		requestedVote := t.ticket.VoteChoices

		if len(requestedVote) == 0 {
			results.noPreferences = append(results.noPreferences, t)
		}

		badVote := false
		for agenda, actualChoice := range t.vote {
			reqChoice, ok := requestedVote[agenda]
			// If no choice set, should be abstain.
			if !ok {
				reqChoice = "abstain"
			}

			if actualChoice != reqChoice {
				badVote = true
			}
		}

		if badVote {
			results.badVotes = append(results.badVotes, t)
		}
	}

	log.Infof("")
	log.Infof("Checked %d most recently voted tickets", cfg.ToCheck)
	log.Infof(" %6d tickets had incorrect votes", len(results.badVotes))
	log.Infof(" %6d tickets not checked due to wrong vote version", len(results.wrongVersion))
	log.Infof(" %6d tickets had no voting preferences set by user", len(results.noPreferences))

	written, err := results.writeFile(logPath)
	if err != nil {
		log.Errorf("Failed to write log file: %v", err)
		return 1
	}
	if written {
		log.Infof("")
		log.Infof("Detailed information written to %s", logPath)
	}

	return 0
}
