package web

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

	"pete/internal/storage"
)

// The euro/chip wire.
//
// gogobee owns the euros and has no inbound API, so it is the only initiator:
// it polls this endpoint for border crossings, moves the money on its side, and
// pushes the verdict back through the durable queue it already uses for
// adventure facts. Pete never calls gogobee. That direction of travel is a
// standing rule of the seam, not an implementation detail — see roster.go.
//
// All three endpoints are bearer-authed with the same ingest token as the
// adventure seam, and all three are idempotent, because the thing on the other
// end of them is a retrying queue and the thing they move is money.
//
// The storage layer under this (internal/storage/games.go) is where the actual
// invariant lives: chips exist only once gogobee confirms it took the euros.
// These handlers are transport, and deliberately nothing more.

// escrowGUID is the body of the two POSTs that name a row.
type escrowGUID struct {
	GUID string `json:"guid"`
}

// escrowVerdict is gogobee's answer: did the money move, and what is the
// player's euro balance now.
type escrowVerdict struct {
	GUID         string  `json:"guid"`
	OK           bool    `json:"ok"`
	Reason       string  `json:"reason,omitempty"`
	BalanceAfter float64 `json:"balance_after"`
}

// handleEscrowPending is gogobee's poll: every crossing waiting to be moved.
//
// It includes rows gogobee claimed but never reported on — see
// storage.PendingEscrow. Re-offering those is the whole reason the guid is an
// idempotency key: if gogobee already moved the euros, the retry is a no-op
// that reports the same answer, and if it died before moving them, the money
// gets moved now instead of being stranded.
func (s *Server) handleEscrowPending(w http.ResponseWriter, r *http.Request) {
	if !s.bearerOK(r) {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	rows, err := storage.PendingEscrow(escrowPollLimit)
	if err != nil {
		slog.Error("games: pending escrow", "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if rows == nil {
		rows = []storage.Escrow{} // an empty poll is [], never null
	}
	writeJSON(w, rows)
}

// escrowPollLimit caps one poll. gogobee polls every few seconds, so a backlog
// drains in a handful of ticks rather than arriving as one enormous body.
const escrowPollLimit = 50

// handleEscrowClaim marks a row as taken. It is not a lock — a row already
// claimed can be claimed again, which is how a stale re-offer works — but a row
// that has already reached a verdict cannot be, which is what stops a settled
// cash-out being paid twice.
//
// The claimed row goes back in the response, so gogobee moves the money against
// the amount and the user *Pete* holds rather than the ones it read a poll ago.
func (s *Server) handleEscrowClaim(w http.ResponseWriter, r *http.Request) {
	if !s.bearerOK(r) {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	var req escrowGUID
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&req); err != nil || req.GUID == "" {
		http.Error(w, "guid is required", http.StatusBadRequest)
		return
	}

	e, err := storage.ClaimEscrow(req.GUID)
	if errors.Is(err, storage.ErrNoSuchEscrow) {
		http.Error(w, "no such escrow", http.StatusNotFound)
		return
	}
	if err != nil {
		slog.Error("games: claim escrow", "guid", req.GUID, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	writeJSON(w, e)
}

// handleEscrowSettled applies gogobee's verdict. This is the only way chips are
// ever created, and it runs exactly once per guid no matter how many times the
// push is redelivered.
//
// An unknown guid is a 400 rather than a shrug: gogobee has, by this point,
// already moved real euros for a row Pete has no record of. Under the contract
// the adventure seam established, a 400 makes gogobee's sender park the row
// instead of retrying it forever — which is right, because no amount of retrying
// invents the missing row. It leaves the payload sitting in gogobee's queue,
// where a human can find it.
func (s *Server) handleEscrowSettled(w http.ResponseWriter, r *http.Request) {
	if !s.bearerOK(r) {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	var v escrowVerdict
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	if v.GUID == "" {
		http.Error(w, "guid is required", http.StatusBadRequest)
		return
	}

	e, err := storage.SettleEscrow(v.GUID, v.OK, v.Reason, v.BalanceAfter)
	if errors.Is(err, storage.ErrNoSuchEscrow) {
		slog.Error("games: verdict for an escrow row we have never heard of — "+
			"gogobee has moved euros against it and Pete cannot honour them",
			"guid", v.GUID, "ok", v.OK)
		http.Error(w, "no such escrow", http.StatusBadRequest)
		return
	}
	if err != nil {
		slog.Error("games: settle escrow", "guid", v.GUID, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	slog.Info("games: escrow settled", "guid", e.GUID, "user", e.MatrixUser,
		"kind", e.Kind, "amount", e.Amount, "state", e.State, "reason", e.Reason)
	writeJSON(w, e)
}

func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(v); err != nil {
		slog.Error("games: write response", "err", err)
	}
}
