package loopin

import (
	"context"
	"database/sql"
	"errors"
	"strings"

	"github.com/btcsuite/btcd/btcec/v2"
	"github.com/btcsuite/btcd/btcutil"
	"github.com/btcsuite/btcd/chaincfg"
	"github.com/btcsuite/btcd/chaincfg/chainhash"
	"github.com/jackc/pgx/v5"
	"github.com/lightninglabs/loop/fsm"
	"github.com/lightninglabs/loop/loopdb"
	"github.com/lightninglabs/loop/loopdb/sqlc"
	"github.com/lightninglabs/loop/staticaddr/deposit"
	"github.com/lightninglabs/loop/staticaddr/version"
	"github.com/lightningnetwork/lnd/clock"
	"github.com/lightningnetwork/lnd/keychain"
	"github.com/lightningnetwork/lnd/lntypes"
	"github.com/lightningnetwork/lnd/lnwallet/chainfee"
)

const OutpointSeparator = ";"

var (
	// ErrInvalidOutpoint is returned when an outpoint contains the outpoint
	// separator.
	ErrInvalidOutpoint = errors.New("outpoint contains outpoint separator")
)

// Querier is the interface that contains all the queries generated by sqlc for
// the static_address_swaps table.
type Querier interface {
	// InsertSwap inserts a new base swap.
	InsertSwap(ctx context.Context, arg sqlc.InsertSwapParams) error

	// InsertHtlcKeys inserts the htlc keys for a swap.
	InsertHtlcKeys(ctx context.Context, arg sqlc.InsertHtlcKeysParams) error

	// InsertStaticAddressLoopIn inserts a new static address loop-in swap.
	InsertStaticAddressLoopIn(ctx context.Context,
		arg sqlc.InsertStaticAddressLoopInParams) error

	// InsertStaticAddressMetaUpdate inserts metadata about loop-in
	// updates.
	InsertStaticAddressMetaUpdate(ctx context.Context,
		arg sqlc.InsertStaticAddressMetaUpdateParams) error

	// UpdateStaticAddressLoopIn updates a loop-in swap.
	UpdateStaticAddressLoopIn(ctx context.Context,
		arg sqlc.UpdateStaticAddressLoopInParams) error

	// GetStaticAddressLoopInSwap retrieves a loop-in swap by its swap hash.
	GetStaticAddressLoopInSwap(ctx context.Context,
		swapHash []byte) (sqlc.GetStaticAddressLoopInSwapRow, error)

	// GetStaticAddressLoopInSwapsByStates retrieves all swaps with the
	// given states. The states string is an input for the IN primitive in
	// sqlite, hence the format needs to be '{State1,State2,...}'.
	GetStaticAddressLoopInSwapsByStates(ctx context.Context,
		states sql.NullString) ([]sqlc.GetStaticAddressLoopInSwapsByStatesRow,
		error)

	// GetLoopInSwapUpdates retrieves all updates for a loop-in swap.
	GetLoopInSwapUpdates(ctx context.Context,
		swapHash []byte) ([]sqlc.StaticAddressSwapUpdate, error)

	// IsStored returns true if a swap with the given hash is stored in the
	// database, false otherwise.
	IsStored(ctx context.Context, swapHash []byte) (bool, error)

	// MapDepositToSwap maps a deposit to a swap in the database.
	MapDepositToSwap(ctx context.Context,
		arg sqlc.MapDepositToSwapParams) error

	// SwapHashForDepositID retrieves the swap hash for the given deposit
	// ID.
	SwapHashForDepositID(ctx context.Context,
		depositID []byte) ([]byte, error)

	// DepositIDsForSwapHash retrieves all deposit IDs for a given swap
	// hash.
	DepositIDsForSwapHash(ctx context.Context,
		swapHash []byte) ([][]byte, error)

	// DepositsForSwapHash retrieves all deposits for a given swap hash.
	DepositsForSwapHash(ctx context.Context,
		swapHash []byte) ([]sqlc.DepositsForSwapHashRow, error)

	// OverrideSelectedSwapAmount updates the selected swap amount for
	// a given swap hash.
	OverrideSelectedSwapAmount(ctx context.Context,
		params sqlc.OverrideSelectedSwapAmountParams) error
}

// BaseDB is the interface that contains all the queries generated by sqlc for
// the static_address_swaps table and transaction functionality.
type BaseDB interface {
	Querier

	// ExecTx allows for executing a function in the context of a database
	// transaction.
	ExecTx(ctx context.Context, txOptions loopdb.TxOptions,
		txBody func(Querier) error) error
}

// SqlStore is the backing store for static address loop-ins.
type SqlStore struct {
	baseDB  BaseDB
	clock   clock.Clock
	network *chaincfg.Params
}

// NewSqlStore constructs a new SQLStore from a BaseDB. The BaseDB is agnostic
// to the underlying driver which can be postgres or sqlite.
func NewSqlStore(db BaseDB, clock clock.Clock,
	network *chaincfg.Params) *SqlStore {

	return &SqlStore{
		baseDB:  db,
		clock:   clock,
		network: network,
	}
}

// GetLoopInByHash returns the loop-in swap with the given hash.
func (s *SqlStore) GetLoopInByHash(ctx context.Context,
	swapHash lntypes.Hash) (*StaticAddressLoopIn, error) {

	var (
		err     error
		swap    sqlc.GetStaticAddressLoopInSwapRow
		updates []sqlc.StaticAddressSwapUpdate
	)
	swap, err = s.baseDB.GetStaticAddressLoopInSwap(ctx, swapHash[:])
	if err != nil {
		return nil, err
	}

	deposits, err := s.baseDB.DepositsForSwapHash(ctx, swapHash[:])
	if err != nil {
		return nil, err
	}

	updates, err = s.baseDB.GetLoopInSwapUpdates(ctx, swapHash[:])
	if err != nil {
		return nil, err
	}

	return toStaticAddressLoopIn(ctx, s.network, swap, deposits, updates)
}

// GetStaticAddressLoopInSwapsByStates returns all static address loop-ins from
// the db that are in the given states.
func (s *SqlStore) GetStaticAddressLoopInSwapsByStates(ctx context.Context,
	states []fsm.StateType) ([]*StaticAddressLoopIn, error) {

	var (
		err     error
		rows    []sqlc.GetStaticAddressLoopInSwapsByStatesRow
		updates []sqlc.StaticAddressSwapUpdate
		loopIn  *StaticAddressLoopIn
	)
	joinedStates := toJointStringStates(states)
	joinedNullStringStates := sql.NullString{
		String: joinedStates,
		Valid:  joinedStates != "",
	}
	rows, err = s.baseDB.GetStaticAddressLoopInSwapsByStates(
		ctx, joinedNullStringStates,
	)
	if err != nil {
		return nil, err
	}

	loopIns := make([]*StaticAddressLoopIn, 0, len(rows))
	for _, row := range rows {
		deposits, err := s.baseDB.DepositsForSwapHash(ctx, row.SwapHash)
		if err != nil {
			return nil, err
		}

		updates, err = s.baseDB.GetLoopInSwapUpdates(
			ctx, row.SwapHash,
		)
		if err != nil {
			return nil, err
		}

		loopIn, err = toStaticAddressLoopIn(
			ctx, s.network, sqlc.GetStaticAddressLoopInSwapRow(row),
			deposits, updates,
		)
		if err != nil {
			return nil, err
		}

		loopIns = append(loopIns, loopIn)
	}

	return loopIns, nil
}

func toJointStringStates(states []fsm.StateType) string {
	return "{" + strings.Join(toStrings(states), ",") + "}"
}

func toStrings(states []fsm.StateType) []string {
	stringStates := make([]string, len(states))
	for i, state := range states {
		stringStates[i] = string(state)
	}

	return stringStates
}

// CreateLoopIn inserts a new loop-in swap into the database. Basic loop-in
// parameters are stored in the swaps table, htlc key information is stored in
// the htlc_keys table, and loop-in specific information is stored in the
// static_address_swaps table.
func (s *SqlStore) CreateLoopIn(ctx context.Context,
	loopIn *StaticAddressLoopIn) error {

	if loopIn == nil {
		return errors.New("loop-in cannot be nil")
	}

	if len(loopIn.Deposits) == 0 {
		return errors.New("loop-in must have at least one deposit")
	}

	amountRequested := int64(loopIn.TotalDepositAmount())
	if loopIn.SelectedAmount > 0 {
		amountRequested = int64(loopIn.SelectedAmount)
	}

	swapArgs := sqlc.InsertSwapParams{
		SwapHash:         loopIn.SwapHash[:],
		Preimage:         loopIn.SwapPreimage[:],
		InitiationTime:   loopIn.InitiationTime,
		AmountRequested:  amountRequested,
		CltvExpiry:       loopIn.HtlcCltvExpiry,
		MaxSwapFee:       int64(loopIn.MaxSwapFee),
		InitiationHeight: int32(loopIn.InitiationHeight),
		ProtocolVersion:  int32(loopIn.ProtocolVersion),
		Label:            loopIn.Label,
	}

	htlcKeyArgs := sqlc.InsertHtlcKeysParams{
		SwapHash:             loopIn.SwapHash[:],
		SenderScriptPubkey:   loopIn.ClientPubkey.SerializeCompressed(),
		ReceiverScriptPubkey: loopIn.ServerPubkey.SerializeCompressed(),
		ClientKeyFamily:      int32(loopIn.HtlcKeyLocator.Family),
		ClientKeyIndex:       int32(loopIn.HtlcKeyLocator.Index),
	}

	// Sanity check, if any of the outpoints contain the outpoint separator.
	// If so, we reject the loop-in to prevent potential issues with
	// parsing.
	for _, outpoint := range loopIn.DepositOutpoints {
		if strings.Contains(outpoint, OutpointSeparator) {
			return ErrInvalidOutpoint
		}
	}

	joinedOutpoints := strings.Join(
		loopIn.DepositOutpoints, OutpointSeparator,
	)
	staticAddressLoopInParams := sqlc.InsertStaticAddressLoopInParams{
		SwapHash:                loopIn.SwapHash[:],
		SwapInvoice:             loopIn.SwapInvoice,
		LastHop:                 loopIn.LastHop,
		QuotedSwapFeeSatoshis:   int64(loopIn.QuotedSwapFee),
		HtlcTimeoutSweepAddress: loopIn.HtlcTimeoutSweepAddress.String(),
		HtlcTxFeeRateSatKw:      int64(loopIn.HtlcTxFeeRate),
		DepositOutpoints:        joinedOutpoints,
		SelectedAmount:          int64(loopIn.SelectedAmount),
		PaymentTimeoutSeconds:   int32(loopIn.PaymentTimeoutSeconds),
		Fast:                    loopIn.Fast,
	}

	updateArgs := sqlc.InsertStaticAddressMetaUpdateParams{
		SwapHash:        loopIn.SwapHash[:],
		UpdateTimestamp: s.clock.Now(),
		UpdateState:     string(loopIn.GetState()),
	}

	return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(),
		func(q Querier) error {
			err := q.InsertSwap(ctx, swapArgs)
			if err != nil {
				return err
			}

			err = q.InsertHtlcKeys(ctx, htlcKeyArgs)
			if err != nil {
				return err
			}

			err = q.InsertStaticAddressLoopIn(
				ctx, staticAddressLoopInParams,
			)
			if err != nil {
				return err
			}

			// Map each deposit to the swap hash in the
			// deposit_to_swap table. This allows us to track which
			// deposits are used for which swaps.
			for _, d := range loopIn.Deposits {
				err = q.MapDepositToSwap(
					ctx, sqlc.MapDepositToSwapParams{
						DepositID: d.ID[:],
						SwapHash:  loopIn.SwapHash[:],
					},
				)
				if err != nil {
					return err
				}
			}

			return q.InsertStaticAddressMetaUpdate(ctx, updateArgs)
		},
	)
}

// UpdateLoopIn updates the loop-in in the database.
func (s *SqlStore) UpdateLoopIn(ctx context.Context,
	loopIn *StaticAddressLoopIn) error {

	var htlcTimeoutSweepTxID string
	if loopIn.HtlcTimeoutSweepTxHash != nil {
		htlcTimeoutSweepTxID = loopIn.HtlcTimeoutSweepTxHash.String()
	}

	updateParams := sqlc.UpdateStaticAddressLoopInParams{
		SwapHash:           loopIn.SwapHash[:],
		HtlcTxFeeRateSatKw: int64(loopIn.HtlcTxFeeRate),
		HtlcTimeoutSweepTxID: sql.NullString{
			String: htlcTimeoutSweepTxID,
			Valid:  htlcTimeoutSweepTxID != "",
		},
	}

	updateArgs := sqlc.InsertStaticAddressMetaUpdateParams{
		SwapHash:        loopIn.SwapHash[:],
		UpdateState:     string(loopIn.GetState()),
		UpdateTimestamp: s.clock.Now(),
	}

	return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(),
		func(q Querier) error {
			err := q.UpdateStaticAddressLoopIn(ctx, updateParams)
			if err != nil {
				return err
			}

			return q.InsertStaticAddressMetaUpdate(ctx, updateArgs)
		},
	)
}

func (s *SqlStore) BatchUpdateSelectedSwapAmounts(ctx context.Context,
	updateAmounts map[lntypes.Hash]btcutil.Amount) error {

	return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(),
		func(q Querier) error {
			for swapHash, amount := range updateAmounts {
				err := q.OverrideSelectedSwapAmount(
					ctx, sqlc.OverrideSelectedSwapAmountParams{
						SwapHash:       swapHash[:],
						SelectedAmount: int64(amount),
					},
				)
				if err != nil {
					return err
				}
			}

			return nil
		})
}

// IsStored returns true if a swap with the given hash is stored in the
// database, false otherwise.
func (s *SqlStore) IsStored(ctx context.Context, swapHash lntypes.Hash) (bool,
	error) {

	return s.baseDB.IsStored(ctx, swapHash[:])
}

// BatchMapDepositsToSwapHashes maps multiple deposits to their respective swap
// hashes in a single transaction.
func (s *SqlStore) BatchMapDepositsToSwapHashes(ctx context.Context,
	depositsToHashes map[deposit.ID]lntypes.Hash) error {

	return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(),
		func(q Querier) error {
			for deposit, swapHash := range depositsToHashes {
				err := q.MapDepositToSwap(
					ctx, sqlc.MapDepositToSwapParams{
						DepositID: deposit[:],
						SwapHash:  swapHash[:],
					},
				)
				if err != nil {
					return err
				}
			}

			return nil
		})
}

// SwapHashesForDepositIDs retrieves the swap hashes for the given deposit IDs.
func (s *SqlStore) SwapHashesForDepositIDs(ctx context.Context,
	depositIDs []deposit.ID) (map[lntypes.Hash][]deposit.ID, error) {

	swapHashes := make(map[lntypes.Hash][]deposit.ID)
	for _, id := range depositIDs {
		swapHash, err := s.baseDB.SwapHashForDepositID(ctx, id[:])
		if err != nil {
			if errors.Is(err, sql.ErrNoRows) ||
				errors.Is(err, pgx.ErrNoRows) {

				return nil, nil
			}

			return nil, err
		}

		if swapHash == nil {
			return nil, nil
		}

		if len(swapHash) != lntypes.HashSize {
			return nil, errors.New("invalid swap hash length")
		}

		swapHashParsed, err := lntypes.MakeHash(swapHash)
		if err != nil {
			return nil, err
		}

		// Place the deposit ID in the map under the
		// corresponding swap hash.
		swapHashes[swapHashParsed] = append(
			swapHashes[swapHashParsed], id,
		)
	}

	return swapHashes, nil
}

// DepositIDsForSwapHash retrieves all deposit IDs for a given swap hash.
func (s *SqlStore) DepositIDsForSwapHash(ctx context.Context,
	swapHash lntypes.Hash) ([]deposit.ID, error) {

	byteIDs, err := s.baseDB.DepositIDsForSwapHash(ctx, swapHash[:])
	if err != nil {
		return nil, err
	}

	depositIDs := make([]deposit.ID, len(byteIDs))
	for i, id := range byteIDs {
		if len(id) != deposit.IdLength {
			return nil, errors.New("invalid deposit ID length")
		}
		copy(depositIDs[i][:], id)
	}

	return depositIDs, nil
}

// toStaticAddressLoopIn converts sql rows to an instant out struct.
func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
	swap sqlc.GetStaticAddressLoopInSwapRow,
	deposits []sqlc.DepositsForSwapHashRow,
	updates []sqlc.StaticAddressSwapUpdate) (*StaticAddressLoopIn, error) {

	swapHash, err := lntypes.MakeHash(swap.SwapHash)
	if err != nil {
		return nil, err
	}

	swapPreImage, err := lntypes.MakePreimage(swap.Preimage)
	if err != nil {
		return nil, err
	}

	clientKey, err := btcec.ParsePubKey(swap.SenderScriptPubkey)
	if err != nil {
		return nil, err
	}

	serverKey, err := btcec.ParsePubKey(swap.ReceiverScriptPubkey)
	if err != nil {
		return nil, err
	}

	var htlcTimeoutSweepTxHash *chainhash.Hash
	if swap.HtlcTimeoutSweepTxID.Valid {
		htlcTimeoutSweepTxHash, err = chainhash.NewHashFromStr(
			swap.HtlcTimeoutSweepTxID.String,
		)
		if err != nil {
			return nil, err
		}
	}

	depositOutpoints := strings.Split(
		swap.DepositOutpoints, OutpointSeparator,
	)

	timeoutAddressString := swap.HtlcTimeoutSweepAddress
	var timeoutAddress btcutil.Address
	if timeoutAddressString != "" {
		timeoutAddress, err = btcutil.DecodeAddress(
			timeoutAddressString, network,
		)
		if err != nil {
			return nil, err
		}
	}

	depositList := make([]*deposit.Deposit, 0, len(deposits))
	for _, d := range deposits {
		id := deposit.ID{}
		err = id.FromByteSlice(d.DepositID)
		if err != nil {
			return nil, err
		}

		sqlcDeposit := sqlc.Deposit{
			DepositID:             id[:],
			TxHash:                d.TxHash,
			Amount:                d.Amount,
			OutIndex:              d.OutIndex,
			ConfirmationHeight:    d.ConfirmationHeight,
			TimeoutSweepPkScript:  d.TimeoutSweepPkScript,
			ExpirySweepTxid:       d.ExpirySweepTxid,
			FinalizedWithdrawalTx: d.FinalizedWithdrawalTx,
		}

		sqlcDepositUpdate := sqlc.DepositUpdate{
			DepositID:       id[:],
			UpdateState:     d.UpdateState.String,
			UpdateTimestamp: d.UpdateTimestamp.Time,
		}
		deposit, err := deposit.ToDeposit(
			sqlcDeposit, sqlcDepositUpdate,
		)
		if err != nil {
			return nil, err
		}

		depositList = append(depositList, deposit)
	}

	loopIn := &StaticAddressLoopIn{
		SwapHash:         swapHash,
		SwapPreimage:     swapPreImage,
		HtlcCltvExpiry:   swap.CltvExpiry,
		MaxSwapFee:       btcutil.Amount(swap.MaxSwapFee),
		InitiationHeight: uint32(swap.InitiationHeight),
		InitiationTime:   swap.InitiationTime,
		ProtocolVersion: version.AddressProtocolVersion(
			swap.ProtocolVersion,
		),
		Label:        swap.Label,
		ClientPubkey: clientKey,
		ServerPubkey: serverKey,
		HtlcKeyLocator: keychain.KeyLocator{
			Family: keychain.KeyFamily(swap.ClientKeyFamily),
			Index:  uint32(swap.ClientKeyIndex),
		},
		SwapInvoice:           swap.SwapInvoice,
		PaymentTimeoutSeconds: uint32(swap.PaymentTimeoutSeconds),
		LastHop:               swap.LastHop,
		QuotedSwapFee:         btcutil.Amount(swap.QuotedSwapFeeSatoshis),
		DepositOutpoints:      depositOutpoints,
		SelectedAmount:        btcutil.Amount(swap.SelectedAmount),
		Fast:                  swap.Fast,
		HtlcTxFeeRate: chainfee.SatPerKWeight(
			swap.HtlcTxFeeRateSatKw,
		),
		HtlcTimeoutSweepAddress: timeoutAddress,
		HtlcTimeoutSweepTxHash:  htlcTimeoutSweepTxHash,
		Deposits:                depositList,
	}

	if len(updates) > 0 {
		lastUpdate := updates[len(updates)-1]
		loopIn.SetState(fsm.StateType(lastUpdate.UpdateState))
	}

	return loopIn, nil
}
