package web

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"pete/internal/config"
	"pete/internal/storage"
)

const testPlayer = "@reala:parodia.dev"

// newCasino is a server with the tables open and one signed-in player. The
// Authenticator is built by hand rather than through OIDC discovery: sign-in is
// a network call, and none of what's under test is about the handshake.
func newCasino(t *testing.T) *Server {
	t.Helper()
	s, _ := newAdvServer(t, "tok")
	s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
	s.cfg.Games = config.GamesConfig{Enabled: true, MatrixServer: "parodia.dev"}
	return s
}

// as returns a request carrying the signed session of the given username. An
// empty username is the pre-casino session: signed in, but nobody the economy
// can name.
func as(t *testing.T, s *Server, username, method, path string, body any) *http.Request {
	t.Helper()
	var buf bytes.Buffer
	if body != nil {
		if err := json.NewEncoder(&buf).Encode(body); err != nil {
			t.Fatal(err)
		}
	}
	r := httptest.NewRequest(method, path, &buf)
	payload, _ := json.Marshal(SessionUser{
		Sub: "sub-1", Username: username, Exp: time.Now().Add(time.Hour).Unix(),
	})
	r.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
	return r
}

// call runs one request against a handler and decodes the table view.
func call(t *testing.T, s *Server, h http.HandlerFunc, r *http.Request) (tableView, int) {
	t.Helper()
	w := httptest.NewRecorder()
	h(w, r)
	var v tableView
	if w.Code == 200 {
		if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
			t.Fatalf("decode table: %v (body %q)", err, w.Body.String())
		}
	}
	return v, w.Code
}

// fund puts chips in front of the player the way the border really does it.
func fund(t *testing.T, chips int64) {
	t.Helper()
	e, err := storage.RequestBuyIn(testPlayer, chips)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := storage.ClaimEscrow(e.GUID); err != nil {
		t.Fatal(err)
	}
	if _, err := storage.SettleEscrow(e.GUID, true, "", 0); err != nil {
		t.Fatal(err)
	}
}

func chipsNow(t *testing.T) int64 {
	t.Helper()
	st, err := storage.Chips(testPlayer)
	if err != nil {
		t.Fatal(err)
	}
	return st.Chips
}

// TestDealTakesTheStakeAndHidesTheHoleCard is the one thing the table cannot get
// wrong: the bet leaves the stack, and the dealer's second card does not leave
// the server.
func TestDealTakesTheStakeAndHidesTheHoleCard(t *testing.T) {
	s := newCasino(t)
	fund(t, 1000)

	v, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal", map[string]int64{"bet": 100}))
	if code != 200 {
		t.Fatalf("deal = %d, want 200", code)
	}
	if v.Chips != 900 {
		t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
	}
	if v.Hand == nil {
		t.Fatal("deal returned no hand")
	}
	if len(v.Hand.Hands[0].Cards) != 2 {
		t.Fatalf("player was dealt %d cards, want 2", len(v.Hand.Hands[0].Cards))
	}

	// A natural settles on the spot and legitimately shows both dealer cards.
	if v.Hand.Phase == "done" {
		return
	}
	if !v.Hand.Hole || len(v.Hand.Dealer) != 1 {
		t.Fatalf("dealer shows %d cards (hole=%v), want 1 with the hole card held back",
			len(v.Hand.Dealer), v.Hand.Hole)
	}
	// And it isn't smuggled out in the dealing script either.
	for _, e := range v.Events {
		if e.Kind == "dealer_hole" && e.Card != nil {
			t.Fatal("the hole card's face went to the browser in the events")
		}
	}
}

// TestHandSettlesIntoTheChipStack plays a hand to the end and checks the chips
// moved by exactly what the engine said they did — and that the hand left the
// felt and landed in the audit log.
func TestHandSettlesIntoTheChipStack(t *testing.T) {
	s := newCasino(t)
	fund(t, 1000)

	v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
	for v.Hand != nil && v.Hand.Phase == "player" {
		v, _ = call(t, s, s.handleMove, as(t, s, "reala", "POST", "/move", map[string]string{"move": "stand"}))
	}
	if v.Hand == nil || v.Hand.Phase != "done" {
		t.Fatalf("hand didn't finish: %+v", v.Hand)
	}

	// 1000, minus the stake, plus whatever came back.
	want := int64(1000) - 100 + v.Hand.Payout
	if got := chipsNow(t); got != want {
		t.Fatalf("chips = %d, want %d (payout %d, outcome %q)", got, want, v.Hand.Payout, v.Hand.Outcome)
	}
	if _, err := storage.LoadLiveHand(testPlayer); err == nil {
		t.Fatal("a settled hand is still sitting on the felt")
	}
	if v.Hand.Outcome == "" {
		t.Fatal("a finished hand with no outcome")
	}

	// The rake only ever comes out of winnings.
	if v.Hand.Outcome == "push" && v.Hand.Payout != 100 {
		t.Fatalf("a push paid %d, want the 100 back untouched", v.Hand.Payout)
	}
	if v.Hand.Net < 0 && v.Hand.Rake != 0 {
		t.Fatalf("a losing hand was raked %d", v.Hand.Rake)
	}
}

// TestOneHandAtATime is the double-click: a second Deal must not overwrite the
// hand the first one is paying for, and must not keep the chips it staked.
func TestOneHandAtATime(t *testing.T) {
	s := newCasino(t)
	fund(t, 1000)

	first, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
	if first.Hand != nil && first.Hand.Phase == "done" {
		t.Skip("dealt a natural; there is no live hand to protect")
	}
	before := chipsNow(t)

	w := httptest.NewRecorder()
	s.handleDeal(w, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
	if w.Code != http.StatusConflict {
		t.Fatalf("second deal = %d, want 409", w.Code)
	}
	if got := chipsNow(t); got != before {
		t.Fatalf("the refused deal cost the player %d chips", before-got)
	}
	// The original hand is untouched.
	live, err := storage.LoadLiveHand(testPlayer)
	if err != nil {
		t.Fatalf("the live hand went missing: %v", err)
	}
	var st struct {
		Hands []struct {
			Cards []struct{} `json:"cards"`
		} `json:"hands"`
	}
	if err := json.Unmarshal(live.State, &st); err != nil {
		t.Fatal(err)
	}
	if len(st.Hands) != 1 || len(st.Hands[0].Cards) != len(first.Hand.Hands[0].Cards) {
		t.Fatal("the refused deal replaced the hand in progress")
	}
}

// TestCannotCashOutMidHand — the stake is on the table, so the session it would
// settle into cannot be closed underneath it.
func TestCannotCashOutMidHand(t *testing.T) {
	s := newCasino(t)
	fund(t, 1000)

	v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
	if v.Hand != nil && v.Hand.Phase == "done" {
		t.Skip("dealt a natural; nothing is in progress")
	}

	w := httptest.NewRecorder()
	s.handleCashOut(w, as(t, s, "reala", "POST", "/cashout", map[string]int64{"amount": 0}))
	if w.Code != http.StatusConflict {
		t.Fatalf("cash-out mid-hand = %d, want 409", w.Code)
	}
	if got := chipsNow(t); got != 900 {
		t.Fatalf("the refused cash-out moved chips: %d, want 900", got)
	}
}

// TestDoubleWithoutTheChipsChangesNothing: a double the player can't cover is
// refused, and refusing it must not quietly pocket the raise.
func TestDoubleWithoutTheChipsChangesNothing(t *testing.T) {
	s := newCasino(t)
	fund(t, 100)

	v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
	if v.Hand == nil || v.Hand.Phase != "player" {
		t.Skip("no live hand to double on")
	}
	if chipsNow(t) != 0 {
		t.Fatal("test wants a player with nothing left to raise with")
	}

	w := httptest.NewRecorder()
	s.handleMove(w, as(t, s, "reala", "POST", "/move", map[string]string{"move": "double"}))
	if w.Code != http.StatusBadRequest {
		t.Fatalf("broke double = %d, want 400", w.Code)
	}
	if got := chipsNow(t); got != 0 {
		t.Fatalf("chips = %d after a refused double, want 0", got)
	}
	// And the hand is still there, still doubleable once they can afford it.
	after, code := call(t, s, s.handleTable, as(t, s, "reala", "GET", "/table", nil))
	if code != 200 || after.Hand == nil || !after.Hand.Double {
		t.Fatalf("the hand should be intact and still doubleable: %d %+v", code, after.Hand)
	}
}

// TestTableNeedsAPlayerTheEconomyCanName. Anonymous is a 401. A session minted
// before the casino existed carries no username, so it can't be mapped to a
// Matrix id — that's a 403, and the fix is to sign in again.
func TestTableNeedsAPlayerTheEconomyCanName(t *testing.T) {
	s := newCasino(t)

	w := httptest.NewRecorder()
	s.handleTable(w, httptest.NewRequest("GET", "/api/games/table", nil))
	if w.Code != http.StatusUnauthorized {
		t.Fatalf("anonymous = %d, want 401", w.Code)
	}

	w = httptest.NewRecorder()
	s.handleTable(w, as(t, s, "", "GET", "/api/games/table", nil))
	if w.Code != http.StatusForbidden {
		t.Fatalf("session with no username = %d, want 403", w.Code)
	}
}

// TestCasinoIsShutWithoutAServerName. No Matrix server name means no player can
// be named to gogobee, so the tables 404 rather than dealing hands whose money
// has nowhere to go.
func TestCasinoIsShutWithoutAServerName(t *testing.T) {
	s := newCasino(t)
	s.cfg.Games.MatrixServer = ""

	w := httptest.NewRecorder()
	s.handleTable(w, as(t, s, "reala", "GET", "/api/games/table", nil))
	if w.Code != http.StatusNotFound {
		t.Fatalf("table with no server name = %d, want 404", w.Code)
	}
}
