package loopin

import (
	"testing"

	"github.com/btcsuite/btcd/btcutil"
	"github.com/lightninglabs/loop/staticaddr/deposit"
	"github.com/stretchr/testify/require"
)

// TestSelectNoChangeDepositsWithMemoryBudget covers the dp-specific behavior
// that the default helper does not expose directly: forced scaling and hard
// budget failures.
func TestSelectNoChangeDepositsWithMemoryBudget(t *testing.T) {
	t.Parallel()

	depositSeven := makeDeposit(31, 0, 7_000, 240)
	depositFour := makeDeposit(32, 0, 4_000, 241)
	depositThree := makeDeposit(33, 0, 3_000, 242)

	testCases := []struct {
		name          string
		maxAmount     btcutil.Amount
		minAmount     btcutil.Amount
		deposits      []*deposit.Deposit
		maxMemory     int
		expected      []*deposit.Deposit
		expectedError error
	}{
		{
			name: "a tight but sufficient budget forces scaled " +
				"buckets and still finds the only valid subset",
			maxAmount: 10_000,
			minAmount: 9_000,
			deposits: []*deposit.Deposit{
				depositSeven, depositFour, depositThree,
			},
			maxMemory: 240,
			expected: []*deposit.Deposit{
				depositSeven, depositThree,
			},
		},
		{
			name: "a budget smaller than two state buckets fails " +
				"explicitly instead of pretending no " +
				"candidate exists",
			maxAmount: 10_000,
			minAmount: 9_000,
			deposits: []*deposit.Deposit{
				depositSeven, depositThree,
			},
			maxMemory:     23,
			expectedError: errAutoloopDPMemoryBudgetTooSmall,
		},
	}

	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()

			deposits, err := selectNoChangeDepositsWithMemoryBudget(
				testCase.maxAmount, testCase.minAmount,
				testCase.deposits, 1_000, 100, nil,
				testCase.maxMemory,
			)

			if testCase.expectedError != nil {
				require.ErrorIs(t, err, testCase.expectedError)
				require.Nil(t, deposits)

				return
			}

			require.NoError(t, err)
			require.Equal(
				t, depositOutpoints(testCase.expected),
				depositOutpoints(deposits),
			)
		})
	}
}

// TestSelectNoChangeDepositsPrefersConfirmedTie verifies unconfirmed deposits
// are not treated as earlier-expiring than confirmed deposits. Their CSV timer
// has not started yet, so a same-value confirmed deposit should win the expiry
// tie-break.
func TestSelectNoChangeDepositsPrefersConfirmedTie(t *testing.T) {
	t.Parallel()

	unconfirmed := makeDeposit(34, 0, 5_000, 0)
	confirmed := makeDeposit(35, 0, 5_000, 200)

	deposits, err := selectNoChangeDeposits(
		5_000, 5_000, []*deposit.Deposit{
			unconfirmed, confirmed,
		}, 1_000, 100, nil,
	)
	require.NoError(t, err)
	require.Equal(
		t, []string{confirmed.OutPoint.String()},
		depositOutpoints(deposits),
	)
}

// TestAutoloopDPSizing verifies the bucket sizing math. These cases are easier
// to understand directly than by inferring the step from a larger selector
// behavior test.
func TestAutoloopDPSizing(t *testing.T) {
	t.Parallel()

	testCases := []struct {
		name            string
		maxAmount       btcutil.Amount
		depositCount    int
		maxMemoryBytes  int
		expectedStep    btcutil.Amount
		expectedBuckets int
		expectedWords   int
		expectedError   error
	}{
		{
			name: "when the table fits exactly the step remains " +
				"one",
			maxAmount:       100,
			depositCount:    1,
			maxMemoryBytes:  24 * 200,
			expectedStep:    1,
			expectedBuckets: 101,
			expectedWords:   1,
		},
		{
			name: "when memory is tight the step increases just " +
				"enough to stay inside budget",
			maxAmount:       100,
			depositCount:    64,
			maxMemoryBytes:  24 * 11,
			expectedStep:    10,
			expectedBuckets: 11,
			expectedWords:   1,
		},
		{
			name: "when the budget cannot hold bucket zero and " +
				"one positive bucket, sizing fails",
			maxAmount:      100,
			depositCount:   64,
			maxMemoryBytes: 23,
			expectedError:  errAutoloopDPMemoryBudgetTooSmall,
			expectedWords:  1,
		},
		{
			name: "a non-positive max amount still rounds the " +
				"step up to one after ceiling division " +
				"returns zero",
			maxAmount:       0,
			depositCount:    64,
			maxMemoryBytes:  24 * 10,
			expectedStep:    1,
			expectedBuckets: 1,
			expectedWords:   1,
		},
		{
			name: "when the deposit count exceeds one word the " +
				"state size rounds up to the next word",
			maxAmount:       100,
			depositCount:    65,
			maxMemoryBytes:  32 * 50,
			expectedStep:    3,
			expectedBuckets: 35,
			expectedWords:   2,
		},
	}

	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()

			require.Equal(
				t, testCase.expectedWords,
				autoloopDPWordsPerState(testCase.depositCount),
			)

			step, bucketCount, err := autoloopDPSizing(
				testCase.maxAmount, testCase.depositCount,
				testCase.maxMemoryBytes,
			)

			if testCase.expectedError != nil {
				require.ErrorIs(t, err, testCase.expectedError)
				require.Zero(t, step)
				require.Zero(t, bucketCount)

				return
			}

			require.NoError(t, err)
			require.Equal(t, testCase.expectedStep, step)
			require.Equal(t, testCase.expectedBuckets, bucketCount)
		})
	}
}

// TestAutoloopDPBucketWeight verifies the compressed bucket mapping directly.
// The selector relies on this helper to avoid the round-up bug where several
// individually rounded deposits can make a valid exact sum unreachable.
func TestAutoloopDPBucketWeight(t *testing.T) {
	t.Parallel()

	testCases := []struct {
		name     string
		value    btcutil.Amount
		step     btcutil.Amount
		expected int
	}{
		{
			name: "values larger than the step are truncated " +
				"into the matching floor bucket",
			value:    10,
			step:     3,
			expected: 3,
		},
		{
			name: "values smaller than the step still consume " +
				"one bucket so they remain selectable",
			value:    2,
			step:     5,
			expected: 1,
		},
	}

	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()

			require.Equal(
				t, testCase.expected,
				autoloopDPBucketWeight(
					testCase.value, testCase.step,
				),
			)
		})
	}
}

// TestCompareResidualLifeSequences isolates the expiry-order helper. This is
// the selector's "what expires sooner?" rule, so the cases state explicitly
// why the helper should consider one sequence earlier, later, or tied.
func TestCompareResidualLifeSequences(t *testing.T) {
	t.Parallel()

	testCases := []struct {
		name     string
		left     []int64
		right    []int64
		expected int
	}{
		{
			name: "the left sequence is earlier when the first " +
				"differing deposit expires sooner",
			left:     []int64{100, 150},
			right:    []int64{100, 200},
			expected: -1,
		},
		{
			name: "the right sequence is earlier when its first " +
				"differing deposit expires sooner",
			left:     []int64{150, 250},
			right:    []int64{150, 200},
			expected: 1,
		},
		{
			name: "a strict prefix is treated as an expiry tie " +
				"so later amount and count rules can decide",
			left:     []int64{100},
			right:    []int64{100, 200},
			expected: 0,
		},
		{
			name: "the same strict-prefix rule applies " +
				"regardless of which side is longer",
			left:     []int64{100, 200},
			right:    []int64{100},
			expected: 0,
		},
		{
			name: "identical residual-life sequences compare as " +
				"equal",
			left:     []int64{100, 200},
			right:    []int64{100, 200},
			expected: 0,
		},
	}

	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()

			result := compareResidualLifeSequences(
				testCase.left, testCase.right,
			)
			require.Equal(t, testCase.expected, result)
		})
	}
}

// TestFilterAutoloopCandidateDeposits covers the low-level filter and sort
// helper so selector tests do not have to infer ordering rules indirectly.
func TestFilterAutoloopCandidateDeposits(t *testing.T) {
	t.Parallel()

	earlierSameValue := makeDeposit(41, 0, 5_000, 200)
	laterSameValueA := makeDeposit(42, 0, 5_000, 210)
	laterSameValueB := makeDeposit(43, 0, 5_000, 210)
	oversized := makeDeposit(44, 0, 9_000, 220)
	unswappable := makeDeposit(45, 0, 4_000, 149)

	selectedDeposits, total := filterAutoloopCandidateDeposits(
		7_000,
		[]*deposit.Deposit{
			laterSameValueB, oversized, earlierSameValue,
			unswappable, laterSameValueA,
		},
		1_000, 100, nil,
	)

	require.Equal(t, btcutil.Amount(15_000), total)
	require.Equal(
		t,
		[]string{
			earlierSameValue.OutPoint.String(),
			laterSameValueA.OutPoint.String(),
			laterSameValueB.OutPoint.String(),
		},
		candidateOutpoints(selectedDeposits),
	)
}

// TestAutoloopDPComparators isolates helper branches that are awkward to hit
// predictably through the full selector alone.
func TestAutoloopDPComparators(t *testing.T) {
	t.Parallel()

	makeCandidateDeposits := func(
		deposits ...*deposit.Deposit) []autoloopCandidateDeposit {

		candidates := make(
			[]autoloopCandidateDeposit, 0, len(deposits),
		)
		for _, deposit := range deposits {
			candidate := autoloopCandidateDeposit{
				deposit:      deposit,
				residualLife: deposit.ConfirmationHeight,
				outpoint:     deposit.OutPoint.String(),
			}
			candidates = append(candidates, candidate)
		}

		return candidates
	}

	t.Run("candidateBeatsState prefers a larger exact sum in the same "+
		"bucket",
		func(t *testing.T) {
			t.Parallel()

			small := makeDeposit(51, 0, 4_000, 300)
			medium := makeDeposit(52, 0, 5_000, 301)
			later := makeDeposit(53, 0, 1_000, 302)

			candidates := makeCandidateDeposits(
				small, medium, later,
			)
			table := newAutoloopDPTable(3, len(candidates))
			table.counts[0] = 0
			table.copyStateFromSource(1, 0, 0, small.Value)
			table.copyStateFromSource(2, 0, 1, medium.Value)

			require.True(t, table.candidateBeatsState(
				2, 1, 2, medium.Value+later.Value, candidates,
			))
		},
	)

	t.Run("stateBeatsStateWithinBand falls back to sum when expiry ties",
		func(t *testing.T) {
			t.Parallel()

			six := makeDeposit(54, 0, 6_000, 300)
			five := makeDeposit(55, 0, 5_000, 300)

			candidates := makeCandidateDeposits(six, five)
			table := newAutoloopDPTable(3, len(candidates))
			table.counts[0] = 0
			table.copyStateFromSource(1, 0, 0, six.Value)
			table.copyStateFromSource(2, 0, 1, five.Value)

			require.True(t, table.stateBeatsStateWithinBand(
				1, 2, candidates,
			))
		},
	)

	t.Run("stateBeatsStateWithinBand falls back to fewer deposits "+
		"when expiry and sum tie",
		func(t *testing.T) {
			t.Parallel()

			six := makeDeposit(56, 0, 6_000, 300)
			five := makeDeposit(57, 0, 5_000, 300)
			one := makeDeposit(58, 0, 1_000, 300)

			candidates := makeCandidateDeposits(six, five, one)
			table := newAutoloopDPTable(4, len(candidates))
			table.counts[0] = 0
			table.copyStateFromSource(1, 0, 0, six.Value)
			table.copyStateFromSource(2, 0, 1, five.Value)
			table.copyStateFromSource(3, 2, 2, five.Value+one.Value)

			require.True(t, table.stateBeatsStateWithinBand(
				1, 3, candidates,
			))
		},
	)
}

// depositOutpoints turns a deposit set into a stable, readable assertion
// surface for the selector tests.
func depositOutpoints(deposits []*deposit.Deposit) []string {
	outpoints := make([]string, 0, len(deposits))
	for _, selectedDeposit := range deposits {
		outpoints = append(outpoints, selectedDeposit.OutPoint.String())
	}

	return outpoints
}

// candidateOutpoints exposes the filtered candidate order in a readable form.
func candidateOutpoints(
	deposits []autoloopCandidateDeposit) []string {

	outpoints := make([]string, 0, len(deposits))
	for _, candidateDeposit := range deposits {
		outpoints = append(outpoints, candidateDeposit.outpoint)
	}

	return outpoints
}
