package main

import (
	"bytes"
	"crypto/ed25519"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"testing"
	"time"
)

// These tests drive a running server. Start it with:
//
//	ARCADE_DEV_FAUCET=1 go run ./cmd/arcade
//
// and run with ARCADE_E2E=http://localhost:8080. They are skipped otherwise so
// that `go test ./...` stays green without a live server.
func baseURL(t *testing.T) string {
	t.Helper()
	u := os.Getenv("ARCADE_E2E")
	if u == "" {
		t.Skip("set ARCADE_E2E to run end-to-end tests")
	}
	return u
}

type client struct {
	t     *testing.T
	base  string
	token string
	pub   ed25519.PublicKey
	priv  ed25519.PrivateKey
}

func newClient(t *testing.T) *client {
	t.Helper()
	pub, priv, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		t.Fatal(err)
	}
	return &client{t: t, base: baseURL(t), pub: pub, priv: priv}
}

func (c *client) do(method, path string, body, out any) int {
	c.t.Helper()
	var buf io.Reader
	if body != nil {
		b, _ := json.Marshal(body)
		buf = bytes.NewReader(b)
	}
	req, err := http.NewRequest(method, c.base+path, buf)
	if err != nil {
		c.t.Fatal(err)
	}
	req.Header.Set("Content-Type", "application/json")
	if c.token != "" {
		req.Header.Set("Authorization", "Bearer "+c.token)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		c.t.Fatal(err)
	}
	defer res.Body.Close()
	if out != nil {
		_ = json.NewDecoder(res.Body).Decode(out)
	}
	return res.StatusCode
}

func (c *client) signIn(nickname string) {
	c.t.Helper()
	pubHex := hex.EncodeToString(c.pub)

	var chal struct{ Challenge string }
	if code := c.do("POST", "/api/auth/challenge",
		map[string]string{"pubkey": pubHex}, &chal); code != 200 {
		c.t.Fatalf("challenge failed: %d", code)
	}
	nonce, _ := hex.DecodeString(chal.Challenge)
	sig := ed25519.Sign(c.priv, nonce)

	var res struct {
		Token string `json:"token"`
	}
	if code := c.do("POST", "/api/auth/verify", map[string]string{
		"pubkey": pubHex, "signature": hex.EncodeToString(sig), "nickname": nickname,
	}, &res); code != 200 {
		c.t.Fatalf("verify failed: %d", code)
	}
	c.token = res.Token
}

func (c *client) fund(msat int64) int64 {
	c.t.Helper()
	var res struct {
		BalanceMsat int64 `json:"balance_msat"`
	}
	if code := c.do("POST", "/api/dev/faucet",
		map[string]int64{"amount_msat": msat}, &res); code != 200 {
		c.t.Fatalf("faucet failed: %d (is ARCADE_DEV_FAUCET=1 set?)", code)
	}
	return res.BalanceMsat
}

func TestSignInAndFund(t *testing.T) {
	c := newClient(t)
	c.signIn("tester")
	if bal := c.fund(50_000_000); bal < 50_000_000 {
		t.Fatalf("balance after faucet = %d", bal)
	}
}

func TestUnauthenticatedRequestsRejected(t *testing.T) {
	c := newClient(t)
	var out map[string]any
	if code := c.do("GET", "/api/balance", nil, &out); code != 401 {
		t.Fatalf("unauthenticated balance returned %d, want 401", code)
	}
	if code := c.do("POST", "/api/bet",
		map[string]any{"game": "rocket", "stake_msat": 1000}, &out); code != 401 {
		t.Fatalf("unauthenticated bet returned %d, want 401", code)
	}
}

func TestCannotBetMoreThanBalance(t *testing.T) {
	c := newClient(t)
	c.signIn("broke")
	// No faucet call: balance is zero.
	var out map[string]any
	code := c.do("POST", "/api/bet",
		map[string]any{"game": "rocket", "stake_msat": 1_000_000}, &out)
	if code != 400 {
		t.Fatalf("betting without funds returned %d, want 400", code)
	}
}

// Play a full round: wait for a betting window, bet, and confirm the stake left
// the balance and the round eventually settles and reveals its seed.
func TestFullRoundLifecycleAndVerification(t *testing.T) {
	c := newClient(t)
	c.signIn("player")
	c.fund(50_000_000)

	const stake = 1_000_000
	var roundID int64

	deadline := time.Now().Add(90 * time.Second)
	for time.Now().Before(deadline) {
		var games struct {
			Rooms []struct {
				RoundID int64  `json:"round_id"`
				Game    string `json:"game"`
				State   string `json:"state"`
			} `json:"rooms"`
		}
		c.do("GET", "/api/games", nil, &games)

		for _, rm := range games.Rooms {
			if rm.Game != "rocket" || rm.State != "betting_open" {
				continue
			}
			var res struct {
				BalanceMsat int64  `json:"balance_msat"`
				Error       string `json:"error"`
			}
			if code := c.do("POST", "/api/bet", map[string]any{
				"game": "rocket", "stake_msat": stake, "nickname": "player",
			}, &res); code == 200 {
				roundID = rm.RoundID
			}
		}
		if roundID != 0 {
			break
		}
		time.Sleep(500 * time.Millisecond)
	}
	if roundID == 0 {
		t.Fatal("never managed to place a bet within 90s")
	}

	// Wait for the round to settle and expose its proof.
	var proof struct {
		Commitment   string   `json:"commitment"`
		ServerSeed   string   `json:"server_seed"`
		ClientSeed   string   `json:"client_seed"`
		Nonce        int64    `json:"nonce"`
		Participants []string `json:"participants"`
	}
	settled := false
	deadline = time.Now().Add(90 * time.Second)
	for time.Now().Before(deadline) {
		if code := c.do("GET", "/api/verify/"+itoa(roundID), nil, &proof); code == 200 {
			settled = true
			break
		}
		time.Sleep(500 * time.Millisecond)
	}
	if !settled {
		t.Fatal("round never settled")
	}

	// The revealed seed must match the commitment published before betting.
	seed, err := hex.DecodeString(proof.ServerSeed)
	if err != nil {
		t.Fatal(err)
	}
	sum := sha256.Sum256(seed)
	if hex.EncodeToString(sum[:]) != proof.Commitment {
		t.Fatalf("commitment mismatch:\n published %s\n actual    %s",
			proof.Commitment, hex.EncodeToString(sum[:]))
	}
	if len(proof.Participants) == 0 {
		t.Fatal("settled round lists no participants")
	}
}

// The books must balance at all times, which the health endpoint reports.
func TestLedgerStaysBalanced(t *testing.T) {
	c := newClient(t)
	var health struct {
		Status        string `json:"status"`
		LedgerSumMsat int64  `json:"ledger_sum_msat"`
	}
	if code := c.do("GET", "/api/health", nil, &health); code != 200 {
		t.Fatalf("health returned %d", code)
	}
	if health.LedgerSumMsat != 0 {
		t.Fatalf("ledger does not balance: sum = %d", health.LedgerSumMsat)
	}
	if health.Status != "ok" {
		t.Fatalf("health status = %q", health.Status)
	}
}

func TestScratchTicketPlaysAndPays(t *testing.T) {
	c := newClient(t)
	c.signIn("scratcher")
	start := c.fund(100_000_000)

	var res struct {
		Outcome struct {
			TierName   string `json:"tier_name"`
			PayoutMsat int64  `json:"payout_msat"`
			Cells      []int  `json:"cells"`
		} `json:"outcome"`
		BalanceMsat int64 `json:"balance_msat"`
	}
	const stake = 1_000_000
	if code := c.do("POST", "/api/scratch/play",
		map[string]any{"ticket_id": "nebula-nine", "stake_msat": stake}, &res); code != 200 {
		t.Fatalf("scratch play returned %d", code)
	}
	if len(res.Outcome.Cells) != 9 {
		t.Fatalf("got %d cells, want 9", len(res.Outcome.Cells))
	}
	want := start - stake + res.Outcome.PayoutMsat
	if res.BalanceMsat != want {
		t.Fatalf("balance = %d, want %d (start %d, stake %d, payout %d)",
			res.BalanceMsat, want, start, stake, res.Outcome.PayoutMsat)
	}
}

func itoa(v int64) string {
	if v == 0 {
		return "0"
	}
	var buf [20]byte
	i := len(buf)
	for v > 0 {
		i--
		buf[i] = byte('0' + v%10)
		v /= 10
	}
	return string(buf[i:])
}

// An auto cash-out target must be accepted by the API and reflected in the
// round, and an invalid one must be refused before any money moves.
func TestAutoCashOutThroughTheAPI(t *testing.T) {
	c := newClient(t)
	c.signIn("autoplayer")
	start := c.fund(50_000_000)

	var out map[string]any
	// A target at or below 1.00 is meaningless and must be rejected.
	code := c.do("POST", "/api/bet", map[string]any{
		"game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 1.0,
	}, &out)
	if code == 200 {
		t.Fatal("a 1.00x auto cash-out target was accepted")
	}

	// An absurd target must be refused rather than overflowing the conversion.
	code = c.do("POST", "/api/bet", map[string]any{
		"game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 1e12,
	}, &out)
	if code == 200 {
		t.Fatal("an absurd auto cash-out target was accepted")
	}

	// Neither rejection may have moved money.
	var bal struct {
		BalanceMsat int64 `json:"balance_msat"`
	}
	c.do("GET", "/api/balance", nil, &bal)
	if bal.BalanceMsat != start {
		t.Fatalf("balance = %d after rejected bets, want %d", bal.BalanceMsat, start)
	}

	// A sensible target should be accepted during a betting window.
	deadline := time.Now().Add(90 * time.Second)
	placed := false
	for time.Now().Before(deadline) && !placed {
		var games struct {
			Rooms []struct {
				Game  string `json:"game"`
				State string `json:"state"`
			} `json:"rooms"`
		}
		c.do("GET", "/api/games", nil, &games)
		for _, rm := range games.Rooms {
			if rm.Game == "rocket" && rm.State == "betting_open" {
				var res map[string]any
				if code := c.do("POST", "/api/bet", map[string]any{
					"game": "rocket", "stake_msat": 1_000_000,
					"auto_cashout": 2.5, "nickname": "autoplayer",
				}, &res); code == 200 {
					placed = true
				}
			}
		}
		if !placed {
			time.Sleep(400 * time.Millisecond)
		}
	}
	if !placed {
		t.Fatal("could not place an auto cash-out bet within 90s")
	}
}

// The complete journey a real player takes, in one test: arrive with no
// account, get funded, play both games, watch the ledger explain every change,
// move sats to a friend, and verify a round independently.
//
// Each step asserts against the ledger rather than against the API's own
// summary, so a bug that reports success while losing money fails here.
func TestFullPlayerJourney(t *testing.T) {
	alice := newClient(t)
	bob := newClient(t)

	// 1. Arrive. No account exists; a keypair is the whole sign-up.
	alice.signIn("alice")
	bob.signIn("bob")

	var bal struct {
		BalanceMsat int64 `json:"balance_msat"`
	}
	alice.do("GET", "/api/balance", nil, &bal)
	if bal.BalanceMsat != 0 {
		t.Fatalf("a brand new player started with %d msat", bal.BalanceMsat)
	}

	// 2. Get funded.
	const funded = 50_000_000
	if got := alice.fund(funded); got != funded {
		t.Fatalf("balance after funding = %d, want %d", got, funded)
	}

	// 3. Scratch a ticket. The balance must move by exactly stake and payout.
	var sc struct {
		Outcome struct {
			TierName   string `json:"tier_name"`
			PayoutMsat int64  `json:"payout_msat"`
			Cells      []int  `json:"cells"`
		} `json:"outcome"`
		Proof struct {
			Commitment string `json:"commitment"`
			ServerSeed string `json:"server_seed"`
		} `json:"proof"`
		BalanceMsat int64 `json:"balance_msat"`
	}
	const scratchStake = 1_000_000
	if code := alice.do("POST", "/api/scratch/play",
		map[string]any{"ticket_id": "nebula-nine", "stake_msat": scratchStake}, &sc); code != 200 {
		t.Fatalf("scratch play returned %d", code)
	}
	wantAfterScratch := int64(funded) - scratchStake + sc.Outcome.PayoutMsat
	if sc.BalanceMsat != wantAfterScratch {
		t.Fatalf("balance after scratch = %d, want %d", sc.BalanceMsat, wantAfterScratch)
	}

	// The scratch proof must verify against its own seed.
	seed, err := hex.DecodeString(sc.Proof.ServerSeed)
	if err != nil {
		t.Fatal(err)
	}
	sum := sha256.Sum256(seed)
	if hex.EncodeToString(sum[:]) != sc.Proof.Commitment {
		t.Fatal("scratch proof does not verify against its own commitment")
	}

	// 4. Play a crash round with an auto cash-out target.
	const stake = 2_000_000
	var roundID int64
	beforeRound := sc.BalanceMsat

	deadline := time.Now().Add(90 * time.Second)
	for time.Now().Before(deadline) && roundID == 0 {
		var games struct {
			Rooms []struct {
				RoundID int64  `json:"round_id"`
				Game    string `json:"game"`
				State   string `json:"state"`
			} `json:"rooms"`
		}
		alice.do("GET", "/api/games", nil, &games)
		for _, rm := range games.Rooms {
			if rm.Game != "rocket" || rm.State != "betting_open" {
				continue
			}
			var res struct {
				BalanceMsat int64 `json:"balance_msat"`
			}
			if code := alice.do("POST", "/api/bet", map[string]any{
				"game": "rocket", "stake_msat": stake,
				"auto_cashout": 1.5, "nickname": "alice",
			}, &res); code == 200 {
				roundID = rm.RoundID
				// The stake must leave immediately, not at settlement.
				if res.BalanceMsat != beforeRound-stake {
					t.Fatalf("balance after bet = %d, want %d",
						res.BalanceMsat, beforeRound-stake)
				}
			}
		}
		if roundID == 0 {
			time.Sleep(400 * time.Millisecond)
		}
	}
	if roundID == 0 {
		t.Fatal("could not join a round within 90s")
	}

	// 5. Wait for settlement and check the outcome is consistent.
	var proof struct {
		Commitment   string   `json:"commitment"`
		ServerSeed   string   `json:"server_seed"`
		ClientSeed   string   `json:"client_seed"`
		Nonce        int64    `json:"nonce"`
		CrashPoint   *int64   `json:"crash_point"`
		Participants []string `json:"participants"`
	}
	settled := false
	deadline = time.Now().Add(120 * time.Second)
	for time.Now().Before(deadline) {
		if code := alice.do("GET", "/api/verify/"+itoa(roundID), nil, &proof); code == 200 {
			settled = true
			break
		}
		time.Sleep(500 * time.Millisecond)
	}
	if !settled {
		t.Fatal("the round never settled")
	}

	// 6. Verify the round independently, the way the client does.
	roundSeed, err := hex.DecodeString(proof.ServerSeed)
	if err != nil {
		t.Fatal(err)
	}
	rs := sha256.Sum256(roundSeed)
	if hex.EncodeToString(rs[:]) != proof.Commitment {
		t.Fatal("settled round does not match its published commitment")
	}
	if proof.CrashPoint == nil {
		t.Fatal("a settled round published no crash point")
	}
	crash := float64(*proof.CrashPoint) / 4294967296.0

	// 7. Balance must reflect the outcome exactly: paid at 1.5x if the round
	// reached the target, nothing otherwise — net of the disclosed fees.
	var after struct {
		BalanceMsat int64 `json:"balance_msat"`
	}

	// Derive the expected payout from the published fee schedule rather than
	// hardcoding it. A test that hardcodes the net would silently stop
	// checking anything the moment the operator changed the rake.
	var sched struct {
		Schedule struct {
			RakePercent  string `json:"rake_percent"`
			RoundingUnit string `json:"rounding_unit"`
		} `json:"schedule"`
	}
	alice.do("GET", "/api/fees", nil, &sched)

	var rakePct float64
	fmt.Sscanf(sched.Schedule.RakePercent, "%f%%", &rakePct)
	var roundUnit int64
	fmt.Sscanf(sched.Schedule.RoundingUnit, "%d msat", &roundUnit)
	if roundUnit < 1 {
		roundUnit = 1
	}

	gross := int64(stake) * 3 / 2
	rake := int64(float64(gross) * rakePct / 100)
	net := (gross - rake) / roundUnit * roundUnit

	// Settlement posts a moment after the reveal; poll briefly.
	wantWin := beforeRound - stake + net
	wantLose := beforeRound - stake
	ok := false
	for i := 0; i < 20; i++ {
		alice.do("GET", "/api/balance", nil, &after)
		if after.BalanceMsat == wantWin || after.BalanceMsat == wantLose {
			ok = true
			break
		}
		time.Sleep(300 * time.Millisecond)
	}
	if !ok {
		t.Fatalf("balance %d is neither the win (%d) nor the loss (%d) outcome",
			after.BalanceMsat, wantWin, wantLose)
	}
	if crash >= 1.5 && after.BalanceMsat != wantWin {
		t.Fatalf("round crashed at %.2fx, above the 1.50x target, but balance is %d not %d "+
			"(gross %d, rake %d, net %d)",
			crash, after.BalanceMsat, wantWin, gross, rake, net)
	}
	if crash < 1.5 && after.BalanceMsat != wantLose {
		t.Fatalf("round crashed at %.2fx, below the 1.50x target, but balance is %d not %d",
			crash, after.BalanceMsat, wantLose)
	}

	// 8. Every balance change must be explained by the ledger.
	var hist struct {
		Entries []struct {
			Kind          string `json:"Kind"`
			AmountMsat    int64  `json:"AmountMsat"`
			BalanceBefore int64  `json:"BalanceBefore"`
			BalanceAfter  int64  `json:"BalanceAfter"`
		} `json:"entries"`
	}
	alice.do("GET", "/api/history", nil, &hist)
	if len(hist.Entries) < 3 {
		t.Fatalf("history has %d entries; expected at least deposit, scratch, bet",
			len(hist.Entries))
	}
	// History is newest-first; walking backwards, each entry's before must be
	// the previous entry's after.
	for i := 0; i < len(hist.Entries)-1; i++ {
		newer, older := hist.Entries[i], hist.Entries[i+1]
		if newer.BalanceBefore != older.BalanceAfter {
			t.Fatalf("ledger history is not continuous: %s starts at %d but the "+
				"preceding %s ended at %d",
				newer.Kind, newer.BalanceBefore, older.Kind, older.BalanceAfter)
		}
		if newer.BalanceAfter != newer.BalanceBefore+newer.AmountMsat {
			t.Fatalf("%s entry does not add up: %d + %d != %d",
				newer.Kind, newer.BalanceBefore, newer.AmountMsat, newer.BalanceAfter)
		}
	}

	// 9. Send sats to a friend; both sides must move by the same amount.
	bobBefore := bob.fund(1)
	aliceBefore := after.BalanceMsat
	const gift = 500_000
	var xfer struct {
		BalanceMsat int64 `json:"balance_msat"`
	}
	if code := alice.do("POST", "/api/transfer", map[string]any{
		"to_pubkey": hex.EncodeToString(bob.pub), "amount_msat": gift,
	}, &xfer); code != 200 {
		t.Fatalf("transfer returned %d", code)
	}
	if xfer.BalanceMsat != aliceBefore-gift {
		t.Fatalf("sender balance = %d, want %d", xfer.BalanceMsat, aliceBefore-gift)
	}
	var bobAfter struct {
		BalanceMsat int64 `json:"balance_msat"`
	}
	bob.do("GET", "/api/balance", nil, &bobAfter)
	if bobAfter.BalanceMsat != bobBefore+gift {
		t.Fatalf("recipient balance = %d, want %d", bobAfter.BalanceMsat, bobBefore+gift)
	}

	// 10. The books must still balance to zero after all of it.
	var health struct {
		Status        string `json:"status"`
		LedgerSumMsat int64  `json:"ledger_sum_msat"`
	}
	alice.do("GET", "/api/health", nil, &health)
	if health.LedgerSumMsat != 0 {
		t.Fatalf("after a full journey the books are off by %d msat", health.LedgerSumMsat)
	}
}
