package web

import (
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"time"

	"pete/internal/games/blackjack"
	"pete/internal/games/uno"
	"pete/internal/storage"
)

// UNO, played for chips at a shared table.
//
// Like hold'em, this is a session: you sit down with a stack, ante into a pot each
// hand, and leave with what is in front of you. Chips cross the border twice —
// sit-down and get-up — and every ante and pot in between moves inside the state
// blob. Solo play is just a table nobody else has joined.
//
// The seam is the same as hold'em: one request plays a human's move plus every
// bot turn it hands off to, returned as a script the felt animates. What a viewer
// is allowed to see is their own hand, the card and colour in play, the pot, and
// how many cards each other seat holds — never the deck, another seat's hand, or
// the face of a card a bot drew. The engine emits every seat's hand (a shared
// stream cannot know who is watching); the redaction that keeps a hand private is
// here, and a missed case fans it to every subscriber.

// unoCardView is one card, ready to draw: colour and face as words, not the
// engine's integers.
type unoCardView struct {
	Color string `json:"color"`
	Value string `json:"value"`
	Wild  bool   `json:"wild"`
}

func viewUnoCard(c uno.Card) unoCardView {
	return unoCardView{Color: c.Color.String(), Value: c.Value.String(), Wild: c.IsWild()}
}

// unoSeatView is one seat at the table: a name, a card count, and a stack. A
// seat's cards are a *count* — there is no field here for what they are.
type unoSeatView struct {
	Name   string `json:"name"`
	Bot    bool   `json:"bot"`
	You    bool   `json:"you"`
	Cards  int    `json:"cards"`
	Stack  int64  `json:"stack"`
	Ante   int64  `json:"ante,omitempty"`
	Uno    bool   `json:"uno"`    // down to one card
	Called bool   `json:"called"` // …and said so. Uno true and this false is a seat you can catch
	Out    bool   `json:"out"`    // not in this hand — mercy-killed, or sitting one out
}

// unoView is a table as one seat may see it.
type unoView struct {
	Tier     uno.Tier      `json:"tier"`
	YourSeat int           `json:"your_seat"`
	Seats    []unoSeatView `json:"seats"`

	Hand     []unoCardView `json:"hand"`     // yours, and only yours
	Playable []int         `json:"playable"` // which of them can legally go down
	Top      unoCardView   `json:"top"`      // the card in play
	Color    string        `json:"color"`    // the colour in play, which a wild renames
	Deck     int           `json:"deck"`     // cards left to draw

	UnoAt     []int `json:"uno_at"`    // your cards that would leave you on one
	Catchable []int `json:"catchable"` // seats sitting on one card they never called

	Turn   int `json:"turn"`
	Dir    int `json:"dir"`
	Dealer int `json:"dealer"`
	HandNo int `json:"hand_no"`

	Pending int `json:"pending"` // No Mercy: the bill a stack has run up

	Pot      int64  `json:"pot"`       // the antes riding on this hand
	Ante     int64  `json:"ante"`      // what each seat puts in
	Stack    int64  `json:"stack"`     // what's in front of you
	BoughtIn int64  `json:"bought_in"` // your own buy-in, from the border ledger
	Phase    string `json:"phase"`

	// The last hand's verdict, so the felt can land it between hands.
	Winner  int    `json:"winner"`
	LastPot int64  `json:"last_pot,omitempty"`
	Rake    int64  `json:"rake,omitempty"`
	Outcome string `json:"outcome,omitempty"`
}

// viewUno renders the table as one seat may see it. viewer is which seat is
// looking — their hand is the only one it will ever put in the payload.
//
// This is the security boundary. The same view fans to every subscriber's stream,
// so a seat that renders anyone else's cards fans them to the whole table.
// TestUnoViewNeverLeaksAnotherSeatsCards renders every seat's view and greps for
// cards that are not theirs.
func viewUno(g uno.State, viewer int) unoView {
	v := unoView{
		Tier:     g.Tier,
		YourSeat: viewer,
		Top:      viewUnoCard(g.Top()),
		Color:    g.Color.String(),
		Deck:     g.Left(),
		Turn:     g.Turn,
		Dir:      g.Dir,
		Dealer:   g.Dealer,
		HandNo:   g.HandNo,
		Pending:  g.Pending,
		Pot:      g.Pot,
		Ante:     g.Tier.Ante,
		BoughtIn: g.BoughtIn, // a table total; the caller overrides it with this seat's own stake
		Phase:    string(g.Phase),
		Winner:   g.Winner,
		LastPot:  g.LastPot,
		Rake:     g.Rake,
		Outcome:  string(g.Outcome),
	}
	if viewer >= 0 && viewer < len(g.Seats) {
		v.Stack = g.Seats[viewer].Stack
	}
	counts := g.Counts()
	for i := range g.Seats {
		p := g.Seats[i]
		live := g.Live(i)
		seat := unoSeatView{
			Name:   p.Name,
			Bot:    p.Bot,
			You:    i == viewer,
			Cards:  counts[i],
			Stack:  p.Stack,
			Ante:   p.Ante,
			Uno:    live && counts[i] == 1,
			Called: i < len(g.Called) && g.Called[i],
			Out:    !live,
		}
		v.Seats = append(v.Seats, seat)
	}

	// The wall. Only the viewer's own hand crosses the wire.
	if viewer >= 0 && viewer < len(g.Hands) {
		for _, c := range g.Hands[viewer] {
			v.Hand = append(v.Hand, viewUnoCard(c))
		}
	}
	if v.Hand == nil {
		v.Hand = []unoCardView{}
	}
	// Empty arrays, never null: the felt indexes into these.
	v.Playable = g.Playable(viewer)
	if v.Playable == nil {
		v.Playable = []int{}
	}
	v.UnoAt = g.UnoAt(viewer)
	if v.UnoAt == nil {
		v.UnoAt = []int{}
	}
	v.Catchable = g.Catchable(viewer)
	if v.Catchable == nil {
		v.Catchable = []int{}
	}
	return v
}

// unoEventView is one beat of the script the felt plays back. The engine attaches
// every seat's hand and drawn cards; this is the wall where the ones the viewer
// isn't entitled to are stripped.
type unoEventView struct {
	Kind  string        `json:"kind"`
	Seat  int           `json:"seat"`
	Card  *unoCardView  `json:"card,omitempty"`
	Color string        `json:"color,omitempty"`
	N     int           `json:"n,omitempty"`
	Left  int           `json:"left"`
	By    int           `json:"by"`
	Text  string        `json:"text,omitempty"`
	Hand  []unoCardView `json:"hand,omitempty"`
}

func viewUnoEvents(evs []uno.Event, viewer int) []unoEventView {
	out := make([]unoEventView, 0, len(evs))
	for _, e := range evs {
		v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, By: e.By, Text: e.Text}
		if e.Color != uno.Wild {
			v.Color = e.Color.String()
		}
		// A hand rides an event only if it is the viewer's own. The engine stamps every
		// seat's hand; this strips the rest.
		if e.Seat == viewer && e.Hand != nil {
			v.Hand = make([]unoCardView, 0, len(e.Hand))
			for _, c := range e.Hand {
				v.Hand = append(v.Hand, viewUnoCard(c))
			}
		}
		// A card rides an event only if it is a card played face up, or one the viewer
		// drew. A bot's (or another human's) drawn card never carries a face.
		if e.Card != nil {
			if e.Kind == uno.EvDraw || e.Kind == uno.EvForced {
				if e.Seat == viewer {
					c := viewUnoCard(*e.Card)
					v.Card = &c
				}
			} else {
				c := viewUnoCard(*e.Card)
				v.Card = &c
			}
		}
		out = append(out, v)
	}
	return out
}

// ---- sitting down ----------------------------------------------------------

// unoSeatRows mirrors the engine's seats into the storage rows that shadow them,
// index for index. A human's staked is the buy-in that crossed the border; a
// bot's is zero.
func unoSeatRows(g uno.State, human string, buyIn int64) []storage.Seat {
	rows := make([]storage.Seat, len(g.Seats))
	for i := range g.Seats {
		p := g.Seats[i]
		row := storage.Seat{Seat: i, Name: p.Name}
		if !p.Bot {
			row.MatrixUser = human
			row.Staked = buyIn
		}
		rows[i] = row
	}
	return rows
}

// handleUnoSit seats a player at a fresh table of their own or at an open chair on
// somebody else's.
func (s *Server) handleUnoSit(w http.ResponseWriter, r *http.Request) {
	user, ok := s.player(w, r)
	if !ok {
		return
	}
	var req struct {
		Tier  string `json:"tier"`
		BuyIn int64  `json:"buyin"`
		Table string `json:"table"`
		Seat  *int   `json:"seat"`
	}
	if err := decodeJSON(r, &req); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	if req.Table != "" {
		s.joinUno(w, r, user, req.Table, req.Seat, req.BuyIn)
		return
	}
	s.openUno(w, r, user, req.Tier, req.BuyIn)
}

// openUno opens a fresh table with the player in seat zero and bots in the rest —
// the old solo flow, now a real shared table with no other humans on it yet.
func (s *Server) openUno(w http.ResponseWriter, r *http.Request, user, tierSlug string, buyIn int64) {
	tier, err := uno.TierBySlug(tierSlug)
	if err != nil {
		writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
		return
	}
	if buyIn < tier.MinBuy || buyIn > tier.MaxBuy {
		writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that isn't a legal buy-in for this table"})
		return
	}

	name := s.displayName(r, user)
	seed1, seed2 := newSeeds()
	g, _, err := uno.New(tier, uno.TableSeats(tier, name, tier.Bots, buyIn), blackjack.DefaultRules().RakePct, seed1, seed2)
	if err != nil {
		slog.Error("games: uno open", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	blob, err := json.Marshal(g)
	if err != nil {
		slog.Error("games: marshal new uno", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	id, err := storage.NewTableID()
	if err != nil {
		slog.Error("games: mint table id", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	t := storage.Table{
		ID: id, Game: gameUno, Tier: tier.Slug, State: blob,
		Seed1: seed1, Seed2: seed2, Phase: string(g.Phase), HandNo: int64(g.HandNo),
	}
	err = storage.OpenSoloTable(t, unoSeatRows(g, user, buyIn), buyIn)
	switch {
	case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
		writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"})
		return
	case errors.Is(err, storage.ErrHandInProgress):
		writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
		return
	case err != nil:
		slog.Error("games: open solo uno table", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	s.writeUnoTable(w, user, nil)
}

// pickOpenUnoSeat chooses a chair to join: the one asked for if it is a bot's,
// otherwise the first bot seat. Returns -1 if there is nowhere to sit.
func pickOpenUnoSeat(g uno.State, want *int) int {
	if want != nil {
		i := *want
		if i >= 0 && i < len(g.Seats) && g.Seats[i].Bot {
			return i
		}
		return -1
	}
	for i := range g.Seats {
		if g.Seats[i].Bot {
			return i
		}
	}
	return -1
}

// joinUno sits a player at an open chair on an existing table, one transaction
// under the table lock, so two people racing for the last seat cannot both win it.
func (s *Server) joinUno(w http.ResponseWriter, r *http.Request, user, tableID string, wantSeat *int, buyIn int64) {
	name := s.displayName(r, user)
	var respErr error
	err := s.tableLocks.withTable(tableID, func() error {
		t, _, err := storage.LoadTable(tableID)
		if errors.Is(err, storage.ErrNoSuchTable) {
			respErr = storage.ErrNoSuchTable
			return nil
		}
		if err != nil {
			return err
		}
		if t.Game != gameUno {
			respErr = uno.ErrUnknownMove
			return nil
		}
		var g uno.State
		if err := json.Unmarshal(t.State, &g); err != nil {
			return err
		}
		if unoPlaying(g) {
			respErr = uno.ErrHandLive // you join between hands
			return nil
		}
		seat := pickOpenUnoSeat(g, wantSeat)
		if seat < 0 {
			respErr = uno.ErrTableFull
			return nil
		}
		if err := g.Occupy(seat, name, buyIn); err != nil {
			respErr = err
			return nil
		}
		blob, err := json.Marshal(g)
		if err != nil {
			return err
		}
		t.State, t.Phase, t.HandNo = blob, string(g.Phase), int64(g.HandNo)
		err = storage.SitDown(storage.Sit{
			Table: t,
			Seat:  storage.Seat{Seat: seat, MatrixUser: user, Name: name, Staked: buyIn},
			BuyIn: buyIn,
		})
		switch {
		case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
			respErr = storage.ErrInsufficientChips
			return nil
		case errors.Is(err, storage.ErrHandInProgress):
			respErr = storage.ErrHandInProgress
			return nil
		case errors.Is(err, storage.ErrSeatTaken), errors.Is(err, storage.ErrStaleTable):
			respErr = storage.ErrSeatTaken
			return nil
		case err != nil:
			return err
		}
		s.publishTable(tableID)
		return nil
	})
	if err != nil {
		slog.Error("games: join uno", "user", user, "table", tableID, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if respErr != nil {
		writeJSONStatus(w, unoJoinStatus(respErr), map[string]string{"error": unoJoinMessage(respErr)})
		return
	}
	s.writeUnoTable(w, user, nil)
}

func unoJoinStatus(err error) int {
	switch {
	case errors.Is(err, storage.ErrNoSuchTable), errors.Is(err, uno.ErrTableFull),
		errors.Is(err, storage.ErrSeatTaken), errors.Is(err, uno.ErrHandLive):
		return http.StatusConflict
	default:
		return http.StatusBadRequest
	}
}

func unoJoinMessage(err error) string {
	switch {
	case errors.Is(err, storage.ErrNoSuchTable):
		return "that table has closed"
	case errors.Is(err, uno.ErrTableFull), errors.Is(err, storage.ErrSeatTaken):
		return "that seat is taken"
	case errors.Is(err, uno.ErrHandLive):
		return "a hand is in play — sit down when it's over"
	case errors.Is(err, uno.ErrBadBuyIn):
		return "that isn't a legal buy-in for this table"
	case errors.Is(err, storage.ErrInsufficientChips):
		return "not enough chips to sit down"
	case errors.Is(err, storage.ErrHandInProgress):
		return "finish the game you're in first"
	default:
		return "you can't sit there"
	}
}

// ---- playing a hand --------------------------------------------------------

// handleUnoMove plays one move at the player's table: a hand move, or dealing the
// next hand. Leaving is its own endpoint.
func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
	user, ok := s.player(w, r)
	if !ok {
		return
	}
	var move uno.Move
	if err := decodeJSON(r, &move); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	if move.Kind == uno.MoveLeave {
		s.leaveUno(w, user)
		return
	}

	tableID, seat, err := storage.PlayerSeat(user)
	if errors.Is(err, storage.ErrNoLiveHand) {
		writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
		return
	}
	if err != nil {
		slog.Error("games: uno move seat", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	var respErr error
	var respEvents []uno.Event
	err = s.tableLocks.withTable(tableID, func() error {
		t, seats, err := storage.LoadTable(tableID)
		if errors.Is(err, storage.ErrNoSuchTable) {
			respErr = storage.ErrNoSuchTable
			return nil
		}
		if err != nil {
			return err
		}
		var g uno.State
		if err := json.Unmarshal(t.State, &g); err != nil {
			return err
		}

		next, evs, aerr := uno.ApplyMove(g, seat, move)
		if aerr != nil {
			respErr = aerr
			return nil
		}

		// A solo session that just ended (the one human got up or busted at the deal)
		// is not a table any more: cash the seat out and close the felt. Only a solo
		// table reaches PhaseDone; a shared table plays on.
		if next.Phase == uno.PhaseDone {
			if err := s.settleUnoLeave(t, next, seat, user); err != nil {
				if errors.Is(err, storage.ErrStaleTable) {
					respErr = storage.ErrStaleTable
					return nil
				}
				return err
			}
			respEvents = evs
			s.publishTable(tableID)
			return nil
		}

		st, err := unoStep(next, evs, seats)
		if err != nil {
			return err
		}
		acting := seats[seat]
		acting.Away = false
		acting.LastSeen = time.Now().Unix()

		t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
		if err := storage.CommitTable(storage.TableCommit{
			Table: t, Seats: []storage.Seat{acting}, Audit: st.Audit,
		}); err != nil {
			if errors.Is(err, storage.ErrStaleTable) {
				respErr = storage.ErrStaleTable
				return nil
			}
			return err
		}
		respEvents = evs
		s.publishTable(tableID)
		return nil
	})
	if err != nil {
		slog.Error("games: uno move", "user", user, "table", tableID, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if respErr != nil {
		writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)})
		return
	}
	s.writeUnoTable(w, user, respEvents)
}

func unoMoveStatus(err error) int {
	switch {
	case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable):
		return http.StatusConflict
	default:
		return http.StatusBadRequest
	}
}

func unoMoveMessage(err error) string {
	switch {
	case errors.Is(err, storage.ErrStaleTable):
		return "the table moved on — take another look"
	case errors.Is(err, storage.ErrNoSuchTable):
		return "that table has closed"
	case errors.Is(err, uno.ErrHandLive):
		return "finish the hand first"
	case errors.Is(err, uno.ErrNoHand):
		return "there's no hand in play — deal one"
	case errors.Is(err, uno.ErrNotYourTurn):
		return "it isn't your turn"
	case errors.Is(err, uno.ErrCantPlay):
		return "that card doesn't go on this one"
	case errors.Is(err, uno.ErrNeedColor):
		return "pick a colour for the wild"
	case errors.Is(err, uno.ErrMustPlayNow):
		return "play the card you drew, or pass"
	case errors.Is(err, uno.ErrCantPass):
		return "draw first, then you can pass"
	case errors.Is(err, uno.ErrMustStack):
		return "answer the stack with a draw card, or take it"
	case errors.Is(err, uno.ErrNoStack):
		return "there's nothing pointed at you to take"
	case errors.Is(err, uno.ErrNoCatch):
		return "there's nobody in that seat to catch"
	default:
		return "that move isn't legal here"
	}
}

// ---- getting up ------------------------------------------------------------

// handleUnoLeave is the get-up endpoint. Leaving is its own route because it is a
// storage operation, not an engine move — the chips cross the border and the felt
// may close.
func (s *Server) handleUnoLeave(w http.ResponseWriter, r *http.Request) {
	user, ok := s.player(w, r)
	if !ok {
		return
	}
	s.leaveUno(w, user)
}

// leaveUno gets a player up from their table, turning what is in front of them
// back into chips. It refuses mid-hand and closes the felt behind the last human.
func (s *Server) leaveUno(w http.ResponseWriter, user string) {
	tableID, seat, err := storage.PlayerSeat(user)
	if errors.Is(err, storage.ErrNoLiveHand) {
		writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
		return
	}
	if err != nil {
		slog.Error("games: uno leave seat", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	var respErr error
	err = s.tableLocks.withTable(tableID, func() error {
		t, _, err := storage.LoadTable(tableID)
		if errors.Is(err, storage.ErrNoSuchTable) {
			respErr = storage.ErrNoSuchTable
			return nil
		}
		if err != nil {
			return err
		}
		var g uno.State
		if err := json.Unmarshal(t.State, &g); err != nil {
			return err
		}
		if unoPlaying(g) {
			respErr = uno.ErrHandLive
			return nil
		}
		if err := s.settleUnoLeave(t, g, seat, user); err != nil {
			if errors.Is(err, storage.ErrStaleTable) {
				respErr = storage.ErrStaleTable
				return nil
			}
			return err
		}
		s.publishTable(tableID)
		return nil
	})
	if err != nil {
		slog.Error("games: uno leave", "user", user, "table", tableID, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if respErr != nil {
		writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)})
		return
	}
	s.writeUnoTable(w, user, nil)
}

// settleUnoLeave vacates a seat, credits the stack home, and closes the table if
// nobody human is left — all inside the caller's lock.
func (s *Server) settleUnoLeave(t storage.Table, g uno.State, seat int, user string) error {
	home, err := g.Vacate(seat)
	if err != nil {
		return err
	}
	blob, err := json.Marshal(g)
	if err != nil {
		return err
	}
	t.State, t.Phase, t.HandNo, t.Deadline = blob, string(g.Phase), int64(g.HandNo), 0
	if err := storage.LeaveTable(storage.Leave{
		Table: t, Seat: seat, MatrixUser: user, Bot: g.Seats[seat].Name, Amount: home,
	}); err != nil {
		return err
	}
	return storage.CloseTable(t.ID)
}

// ---- the response ----------------------------------------------------------

// writeUnoTable answers with the whole page state — the money and the table as the
// player's own seat may see it — plus, when a move produced one, the redacted event
// script for that seat to animate.
func (s *Server) writeUnoTable(w http.ResponseWriter, user string, evs []uno.Event) {
	v, err := s.table(user)
	if err != nil {
		slog.Error("games: uno table", "user", user, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if len(evs) > 0 {
		if _, seat, serr := storage.PlayerSeat(user); serr == nil {
			v.UnoEvents = viewUnoEvents(evs, seat)
		}
	}
	writeJSON(w, v)
}
