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

package vspd

import (
	"context"
	"fmt"

	"github.com/decred/dcrd/blockchain/stake/v5"
	"github.com/decred/dcrd/chaincfg/chainhash"
	"github.com/decred/dcrd/txscript/v4/stdaddr"
	"github.com/decred/dcrd/wire"
	"github.com/decred/vspd/database"
)

type spentTicket struct {
	dbTicket     database.Ticket
	expiryHeight int64
	heightSpent  int64
	spendingTx   *wire.MsgTx
}

func (s *spentTicket) voted() bool {
	return stake.IsSSGen(s.spendingTx)
}

func (s *spentTicket) missed() bool {
	// The following switch statement is a heuristic to estimate whether a
	// ticket was missed or expired based on its revoke height. Absolute
	// precision is not needed here as this status is only used to report VSP
	// stats via /vspinfo, which could be forged by a malicious VSP operator
	// anyway.
	switch {
	case s.heightSpent < s.expiryHeight:
		// A ticket revoked before expiry height was definitely missed.
		return true
	case s.heightSpent == s.expiryHeight:
		// If a ticket was revoked on exactly expiry height, assume it expired.
		// This might be incorrect if DCP-0009 was not active and a missed
		// ticket was coincidentally revoked on exactly the expiry height.
		return false
	case s.heightSpent == s.expiryHeight+1:
		// Revoking after the expiry height was only possible before DCP-0009
		// activated. Cannot be certain if missed or expired, but if it was
		// revoked exactly in the first block an expired ticket could have
		// possibly been revoked, there is a high probability the voter was
		// online and didn't miss the vote, so assume expired.
		return false
	default:
		// Revoking after the expiry height was only possible before DCP-0009
		// activated. Cannot be certain if missed or expired, but if it was
		// revoked later than the first block an expired ticket could have
		// possibly been revoked, it is probably because the voter was offline
		// and there is a much higher probability that the ticket was missed, so
		// assume missed.
		return true
	}
}

// findSpentTickets attempts to find transactions that vote/revoke the provided
// tickets by matching the payment script of the ticket's commitment address
// against the block filters of the mainchain blocks between the provided start
// block and the current best block. Returns any found spent tickets and the
// height of the most recent scanned block.
func (v *Vspd) findSpentTickets(ctx context.Context, toCheck database.TicketList,
	startHeight int64) ([]spentTicket, int64, error) {
	dcrdClient, _, err := v.dcrd.Client()
	if err != nil {
		return nil, 0, err
	}

	endHeight, err := dcrdClient.GetBlockCount()
	if err != nil {
		return nil, 0, fmt.Errorf("dcrd.GetBlockCount error: %w", err)
	}

	if startHeight > endHeight {
		return nil, 0, fmt.Errorf("start height %d greater than best block height %d",
			startHeight, endHeight)
	}

	numBlocks := 1 + endHeight - startHeight

	// Only log if checking a larger number of blocks to avoid spam.
	if numBlocks > 5 {
		v.log.Debugf("Scanning %d blocks for %s",
			numBlocks, pluralize(len(toCheck), "spent ticket"))
	}

	// Get commitment address payment scripts and parse hashes for each ticket
	// prior to the main loop. Two slices are needed because payment scripts
	// must be in their own slice to be passed into the MatchAny func.
	type ticketTuple struct {
		dbTicket database.Ticket
		hash     chainhash.Hash
	}

	tickets := make([]ticketTuple, 0, len(toCheck))
	scripts := make([][]byte, 0, len(toCheck))
	for _, ticket := range toCheck {
		parsedAddr, err := stdaddr.DecodeAddress(ticket.CommitmentAddress, v.network)
		if err != nil {
			return nil, 0, err
		}
		_, script := parsedAddr.PaymentScript()

		hash, err := chainhash.NewHashFromStr(ticket.Hash)
		if err != nil {
			return nil, 0, err
		}

		tickets = append(tickets, ticketTuple{ticket, *hash})
		scripts = append(scripts, script)
	}

	spent := make([]spentTicket, 0)

	for iHeight := startHeight; iHeight <= endHeight; iHeight++ {
		// Exit early if context has been canceled.
		if ctx.Err() != nil {
			return nil, 0, context.Canceled
		}

		iHash, err := dcrdClient.GetBlockHash(iHeight)
		if err != nil {
			return nil, 0, err
		}

		iHeader, err := dcrdClient.GetBlockHeader(iHash)
		if err != nil {
			return nil, 0, err
		}

		verifyProof := v.network.DCP5Active(iHeight)
		key, filter, err := dcrdClient.GetCFilterV2(iHeader, verifyProof)
		if err != nil {
			return nil, 0, err
		}

		if !filter.MatchAny(key, scripts) {
			// No tickets are spent in this block, continue to the next one.
			continue
		}

		// Filter match means a ticket is likely spent in this block. Get the
		// full block to confirm.
		iBlock, err := dcrdClient.GetBlock(iHash)
		if err != nil {
			return nil, 0, err
		}

		for i := 0; i < len(tickets); i++ {
			// The regular transaction tree does not need to be checked because
			// tickets can only be spent by vote or revoke transactions which
			// are always in the stake tree.
			for _, blkTx := range iBlock.STransactions {
				if !txSpendsTicket(blkTx, tickets[i].hash) {
					continue
				}

				// Confirmed - ticket is spent in block.

				spent = append(spent, spentTicket{
					dbTicket: tickets[i].dbTicket,
					expiryHeight: tickets[i].dbTicket.PurchaseHeight +
						int64(v.network.TicketMaturity) +
						int64(v.network.TicketExpiry),
					heightSpent: iHeight,
					spendingTx:  blkTx,
				})

				// Remove this ticket and its script before continuing with the
				// next one.
				tickets = nonOrderPreservingRemove(tickets, i)
				scripts = nonOrderPreservingRemove(scripts, i)

				// Current index has been removed which means everything else
				// moved up one and thus the same index needs to be repeated.
				i--

				// Check next ticket.
				break
			}
		}

		if len(tickets) == 0 {
			// Found spenders for all tickets, stop searching.
			break
		}
	}

	return spent, endHeight, nil
}

// txSpendsTicket returns true if the passed tx has an input that spends the
// specified output.
func txSpendsTicket(tx *wire.MsgTx, outputHash chainhash.Hash) bool {
	for _, txIn := range tx.TxIn {
		prevOut := &txIn.PreviousOutPoint
		if prevOut.Index == 0 && prevOut.Hash == outputHash {
			return true // Found spender.
		}
	}
	return false
}

// nonOrderPreservingRemove removes index i from slice s. The order of the slice
// is not preserved, however the important property of the function is that
// removing the same index from two slices of identical length will modify the
// order of each slice in the same way. This allows a 1-to-1 mapping between two
// (or more) slices to be preserved.
func nonOrderPreservingRemove[T any](s []T, i int) []T {
	s[i] = s[len(s)-1]
	return s[:len(s)-1]
}

// pluralize suffixes the provided noun with "s" if n is not 1, then
// concatenates n and noun with a space between them. For example:
//
//	(0, "biscuit") will return "0 biscuits"
//	(1, "biscuit") will return "1 biscuit"
//	(3, "biscuit") will return "3 biscuits"
func pluralize(n int, noun string) string {
	if n != 1 {
		noun += "s"
	}
	return fmt.Sprintf("%d %s", n, noun)
}
