package lightning_test

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/drjones/quantum-arcade/pkg/lightning"
)

// These exercise the Alby Hub client against a mock of its REST API.
//
// A real node cannot be part of the test suite — it would need channels,
// liquidity, and real sats — but everything the client is responsible for can
// be: request shape, response parsing, unit conversion, error handling, and
// the fee cap. Those are where a client loses money, not the routing.

// albyMock stands in for Alby Hub. Handlers can be overridden per test.
type albyMock struct {
	*httptest.Server
	lastPath    string
	lastBody    map[string]any
	invoiceResp string
	paymentResp string
	balanceResp string
	status      int
}

func newAlbyMock(t *testing.T) *albyMock {
	t.Helper()
	m := &albyMock{
		invoiceResp: `{"paymentHash":"abc123","invoice":"lnbc500n1...",
		               "amount":500,"state":"unpaid",
		               "expiresAt":"2030-01-01T00:00:00Z"}`,
		paymentResp: `{"paymentHash":"pay123","preimage":"deadbeef",
		               "amountSat":500,"feesPaidSat":2,"state":"settled"}`,
		balanceResp: `{"lightning":{"totalSpendableSat":15000}}`,
		status:      200,
	}
	m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		m.lastPath = r.URL.Path
		if r.Body != nil {
			_ = json.NewDecoder(r.Body).Decode(&m.lastBody)
		}
		if auth := r.Header.Get("Authorization"); !strings.HasPrefix(auth, "Bearer ") {
			w.WriteHeader(http.StatusUnauthorized)
			_, _ = w.Write([]byte(`{"error":"missing token"}`))
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(m.status)
		switch {
		case strings.Contains(r.URL.Path, "/invoices"):
			_, _ = w.Write([]byte(m.invoiceResp))
		case strings.Contains(r.URL.Path, "/payments"):
			_, _ = w.Write([]byte(m.paymentResp))
		case strings.Contains(r.URL.Path, "/balances"):
			_, _ = w.Write([]byte(m.balanceResp))
		default:
			w.WriteHeader(http.StatusNotFound)
		}
	}))
	t.Cleanup(m.Close)
	return m
}

func TestAlbySendsBearerToken(t *testing.T) {
	m := newAlbyMock(t)
	node := lightning.NewAlbyNode(m.URL, "test-token")

	if _, err := node.Balance(context.Background()); err != nil {
		t.Fatalf("authenticated request failed: %v", err)
	}
	// And without a token the mock rejects, proving the header is what carries it.
	bare := lightning.NewAlbyNode(m.URL, "")
	if _, err := bare.Balance(context.Background()); err == nil {
		t.Fatal("a request with no token succeeded")
	}
}

// Alby speaks satoshis; the ledger speaks millisatoshis. A conversion error
// here is a factor-of-1000 money bug.
func TestAlbyConvertsSatsToMillisats(t *testing.T) {
	m := newAlbyMock(t)
	node := lightning.NewAlbyNode(m.URL, "tok")

	bal, err := node.Balance(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if bal != 15_000_000 {
		t.Fatalf("balance = %d msat, want 15000000 (15000 sats)", bal)
	}

	inv, err := node.CreateInvoice(context.Background(), 500_000, "test")
	if err != nil {
		t.Fatal(err)
	}
	if inv.AmountMsat != 500_000 {
		t.Fatalf("invoice amount = %d msat, want 500000", inv.AmountMsat)
	}
	// The request must have asked for sats, not millisats.
	if got := m.lastBody["amount"]; got != float64(500) {
		t.Fatalf("asked Alby for amount %v, want 500 sats", got)
	}
}

// The fee cap must actually be enforced. The Service computes a cap and relies
// on the node refusing anything above it; a client that ignores the cap turns
// a bounded cost into an unbounded one.
func TestAlbyRefusesPaymentAboveTheFeeCap(t *testing.T) {
	m := newAlbyMock(t)
	// Alby reports a 50-sat fee on this route.
	m.paymentResp = `{"paymentHash":"pay1","preimage":"ab","amountSat":1000,
	                  "feesPaidSat":50,"state":"settled"}`
	node := lightning.NewAlbyNode(m.URL, "tok")

	// Cap of 10 sats. 50 > 10, so this must fail.
	_, err := node.PayInvoice(context.Background(), "lnbc...", 10_000)
	if err == nil {
		t.Fatal("a payment costing 50 sats was accepted under a 10 sat cap")
	}
	if !errors.Is(err, lightning.ErrPaymentFailed) {
		t.Fatalf("got %v, want ErrPaymentFailed", err)
	}
}

func TestAlbyAcceptsPaymentWithinTheFeeCap(t *testing.T) {
	m := newAlbyMock(t)
	m.paymentResp = `{"paymentHash":"pay1","preimage":"ab","amountSat":1000,
	                  "feesPaidSat":2,"state":"settled"}`
	node := lightning.NewAlbyNode(m.URL, "tok")

	p, err := node.PayInvoice(context.Background(), "lnbc...", 10_000)
	if err != nil {
		t.Fatalf("a payment within the cap was refused: %v", err)
	}
	if p.FeeMsat != 2_000 {
		t.Fatalf("fee = %d msat, want 2000", p.FeeMsat)
	}
}

// A payment that has not settled must not be reported as success. Treating
// "pending" as paid would credit a withdrawal that may still fail.
func TestAlbyUnsettledPaymentIsAnError(t *testing.T) {
	m := newAlbyMock(t)
	m.paymentResp = `{"paymentHash":"p","preimage":"","amountSat":1000,
	                  "feesPaidSat":1,"state":"pending"}`
	node := lightning.NewAlbyNode(m.URL, "tok")

	if _, err := node.PayInvoice(context.Background(), "lnbc...", 100_000); err == nil {
		t.Fatal("a pending payment was reported as settled")
	}
}

func TestAlbyInvoiceSettlementState(t *testing.T) {
	m := newAlbyMock(t)
	node := lightning.NewAlbyNode(m.URL, "tok")

	m.invoiceResp = `{"paymentHash":"h","invoice":"lnbc","amount":300,
	                  "state":"unpaid","expiresAt":"2030-01-01T00:00:00Z"}`
	settled, amt, err := node.LookupInvoice(context.Background(), "h")
	if err != nil {
		t.Fatal(err)
	}
	if settled {
		t.Fatal("an unpaid invoice reported as settled")
	}
	if amt != 300_000 {
		t.Fatalf("amount = %d msat, want 300000", amt)
	}

	m.invoiceResp = `{"paymentHash":"h","invoice":"lnbc","amount":300,
	                  "state":"settled","expiresAt":"2030-01-01T00:00:00Z"}`
	settled, _, err = node.LookupInvoice(context.Background(), "h")
	if err != nil {
		t.Fatal(err)
	}
	if !settled {
		t.Fatal("a settled invoice reported as unpaid")
	}
}

// An error from the node must surface, not be swallowed into a zero value that
// downstream code reads as "no balance" or "not settled".
func TestAlbyErrorsSurface(t *testing.T) {
	m := newAlbyMock(t)
	m.status = 500
	m.balanceResp = `{"error":"node offline"}`
	node := lightning.NewAlbyNode(m.URL, "tok")

	if _, err := node.Balance(context.Background()); err == nil {
		t.Fatal("a 500 from the node was not reported as an error")
	}
}

func TestAlbyMalformedResponseIsAnError(t *testing.T) {
	m := newAlbyMock(t)
	m.balanceResp = `not json at all`
	node := lightning.NewAlbyNode(m.URL, "tok")

	if _, err := node.Balance(context.Background()); err == nil {
		t.Fatal("a malformed response was accepted")
	}
}

// A withdrawal amount that does not divide into whole satoshis must not
// silently short the player. Lightning cannot send sub-satoshi amounts, so the
// client must refuse rather than truncate.
func TestAlbyRefusesSubSatoshiPrecisionLoss(t *testing.T) {
	m := newAlbyMock(t)
	node := lightning.NewAlbyNode(m.URL, "tok")

	// 1500 msat is 1.5 sats. Truncating sends 1 sat while the ledger debited
	// 1500 msat, quietly costing the player 500 msat.
	_, err := node.CreateInvoice(context.Background(), 1_500, "dust")
	if err == nil {
		t.Fatal("an amount with sub-satoshi precision was accepted; " +
			"it would be truncated and the difference lost")
	}
}

// The client must respect a cancelled context rather than hanging.
func TestAlbyHonoursContextCancellation(t *testing.T) {
	m := newAlbyMock(t)
	node := lightning.NewAlbyNode(m.URL, "tok")

	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	if _, err := node.Balance(ctx); err == nil {
		t.Fatal("a cancelled context did not stop the request")
	}
}

// The client must satisfy the Node interface the Service depends on.
func TestAlbyImplementsNode(t *testing.T) {
	var _ lightning.Node = (*lightning.AlbyNode)(nil)
}
