package controllers

import (
	"errors"
	"fmt"
	"log"
	"net/http"
	"strconv"
	"strings"
	"time"

	"nearle/middleware"
	"nearle/models"
	"nearle/repositories"
	"nearle/services"
	"nearle/utils"

	"github.com/gofiber/fiber/v2"
)

// HTTP face of the POS terminal ingest.
//
// These handlers break this codebase's house style in one respect, on purpose:
// they answer with a bare ack rather than the usual
// `{code, message, status, details}` envelope. The terminal reads `accepted`
// from the top level of the body and marks a bill synced only if its id is
// there — wrapping the ack would leave every till queueing for ever.
//
// The status code carries the other half of the contract:
//
//   - **200** — the batch was processed. Individual bills may still have been
//     refused; the ack says which.
//   - **4xx** — the request itself is wrong (unreadable body, unknown outlet).
//     The terminal treats these as non-retryable and halts, so a person is
//     told rather than the broker hammered.
//   - **5xx** — the outcome is unknown. The terminal keeps every bill and
//     retries with backoff. This is the right answer when the database is
//     having a bad minute: *never* ack a batch that did not commit.
type PosController struct {
	posService services.PosService
}

func NewPosController(posService services.PosService) *PosController {
	return &PosController{posService: posService}
}

// IngestOrders receives a batch of completed counter bills.
func (ctl *PosController) IngestOrders(c *fiber.Ctx) error {
	var batch models.PosOrderBatch

	if err := c.BodyParser(&batch); err != nil {
		return c.Status(http.StatusBadRequest).JSON(fiber.Map{
			"code":    http.StatusBadRequest,
			"message": "could not read the batch: " + err.Error(),
			"status":  false,
		})
	}

	if strings.TrimSpace(batch.Storeid) == "" {
		return c.Status(http.StatusBadRequest).JSON(fiber.Map{
			"code":    http.StatusBadRequest,
			"message": "store_id is required",
			"status":  false,
		})
	}

	ack, err := ctl.posService.IngestOrders(batch)
	if err != nil {
		return posIngestError(c, "IngestOrders", err)
	}

	return c.Status(http.StatusOK).JSON(ack)
}

// IngestCustomers receives shoppers registered at a till.
func (ctl *PosController) IngestCustomers(c *fiber.Ctx) error {
	var batch models.PosCustomerBatch

	if err := c.BodyParser(&batch); err != nil {
		return c.Status(http.StatusBadRequest).JSON(fiber.Map{
			"code":    http.StatusBadRequest,
			"message": "could not read the batch: " + err.Error(),
			"status":  false,
		})
	}

	if strings.TrimSpace(batch.Storeid) == "" {
		return c.Status(http.StatusBadRequest).JSON(fiber.Map{
			"code":    http.StatusBadRequest,
			"message": "store_id is required",
			"status":  false,
		})
	}

	ack, err := ctl.posService.IngestCustomers(batch)
	if err != nil {
		return posIngestError(c, "IngestCustomers", err)
	}

	return c.Status(http.StatusOK).JSON(ack)
}

// IngestHealth records one heartbeat from a till.
//
// The same heartbeat the broker carries, over HTTP, because presence was
// previously reachable *only* over MQTT — a terminal configured for the HTTP
// route reported bills perfectly and never appeared on the fleet board at all,
// with nothing anywhere to say why. A monitoring feature that silently does not
// exist on one of two supported transports is worse than no feature.
//
// Answers 202 rather than 200: nothing is committed, and the till is told not
// to wait on it. Failures are swallowed for the same reason the MQTT path
// swallows them — a terminal that cannot say how it is must still sell, and a
// blank square on a dashboard beats a till that stopped because Redis was busy.
func (ctl *PosController) IngestHealth(c *fiber.Ctx) error {
	var health models.PosHealth

	if err := c.BodyParser(&health); err != nil {
		return c.Status(http.StatusBadRequest).JSON(fiber.Map{
			"code":    http.StatusBadRequest,
			"message": "could not read the heartbeat: " + err.Error(),
			"status":  false,
		})
	}

	// Over MQTT these come from the topic. There is no topic here, so the body
	// is the only source and both are required — a heartbeat that cannot say
	// which till it belongs to is unfilable.
	if strings.TrimSpace(health.Terminalid) == "" {
		return c.Status(http.StatusBadRequest).JSON(fiber.Map{
			"code": http.StatusBadRequest, "status": false,
			"message": "terminal_id is required",
		})
	}
	if strings.TrimSpace(health.Locationid) == "" {
		return c.Status(http.StatusBadRequest).JSON(fiber.Map{
			"code": http.StatusBadRequest, "status": false,
			"message": "location_id is required",
		})
	}

	// Matches the consumer: a bare {"status":"offline"} is a Last Will and must
	// survive as-is, but an unset status from a till that is plainly talking to
	// us means online.
	if strings.TrimSpace(health.Status) == "" {
		health.Status = "online"
	}

	if err := ctl.posService.RecordHealth(c.Context(), health); err != nil {
		// Logged, not returned. See above — the till must not slow down for it.
		log.Printf("pos: could not record heartbeat from %s/%s over HTTP: %v",
			health.Locationid, health.Terminalid, err)
	}

	return c.Status(http.StatusAccepted).JSON(fiber.Map{
		"status": true, "code": http.StatusAccepted,
	})
}

// Catalogue answers a terminal's product pull.
func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
	storeID := strings.TrimSpace(c.Query("store_id"))
	if storeID == "" {
		return c.Status(http.StatusBadRequest).JSON(fiber.Map{
			"code":    http.StatusBadRequest,
			"message": "store_id is required",
			"status":  false,
		})
	}

	page, _ := strconv.Atoi(c.Query("page", "0"))
	pageSize, _ := strconv.Atoi(c.Query("page_size", "500"))

	result, err := ctl.posService.Catalogue(storeID, c.Query("since"), page, pageSize)
	if err != nil {
		return posIngestError(c, "Catalogue", err)
	}

	return c.Status(http.StatusOK).JSON(result)
}

// TerminalHealth returns one till's live state, for a support call that starts
// with a terminal code.
func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
	terminalID := strings.TrimSpace(c.Query("terminal_id"))
	if terminalID == "" {
		return c.Status(http.StatusBadRequest).JSON(fiber.Map{
			"code": http.StatusBadRequest, "message": "terminal_id is required", "status": false,
		})
	}

	fields, err := ctl.posService.TerminalHealth(c.Context(), terminalID)
	if err != nil {
		return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
			"code": http.StatusServiceUnavailable, "message": err.Error(), "status": false,
		})
	}

	if fields == nil {
		// Not an error. The till has simply not reported inside its TTL, which
		// is the answer the caller wanted — said plainly rather than as a 404
		// that reads like the terminal does not exist.
		return c.JSON(fiber.Map{
			"code":   http.StatusOK,
			"status": true,
			"details": fiber.Map{
				"terminal_id": terminalID,
				"status":      "offline",
				"reason":      "no heartbeat received within the presence window",
			},
		})
	}

	return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": fields})
}

// LocationHealth returns every till at a shop — the "which counters are dark"
// board. Tills that have stopped reporting come back marked offline rather than
// being omitted, because a missing till is exactly what somebody is looking for.
func (ctl *PosController) LocationHealth(c *fiber.Ctx) error {
	locationID := strings.TrimSpace(c.Query("location_id"))
	if locationID == "" {
		return c.Status(http.StatusBadRequest).JSON(fiber.Map{
			"code": http.StatusBadRequest, "message": "location_id is required", "status": false,
		})
	}

	terminals, err := ctl.posService.LocationHealth(c.Context(), locationID)
	if err != nil {
		return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
			"code": http.StatusServiceUnavailable, "message": err.Error(), "status": false,
		})
	}

	online := 0
	for _, t := range terminals {
		if t["status"] == "online" {
			online++
		}
	}

	return c.JSON(fiber.Map{
		"code":   http.StatusOK,
		"status": true,
		"details": fiber.Map{
			"location_id": locationID,
			"total":       len(terminals),
			"online":      online,
			"terminals":   terminals,
		},
	})
}

// ---------------------------------------------------------------- Sales reads
//
// Unlike the ingest handlers above, these answer in the usual
// `{code, message, status, details}` envelope — they are read by the web app,
// not by a terminal, and nothing about them is bound to the till's contract.

// posSalesFilter reads the shared query parameters.
func posSalesFilter(c *fiber.Ctx) (models.PosSalesFilter, error) {
	locationID, err := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
	if err != nil || locationID <= 0 {
		return models.PosSalesFilter{}, fmt.Errorf("locationid is required")
	}

	pageno, _ := strconv.Atoi(c.Query("pageno", "0"))
	pagesize, _ := strconv.Atoi(c.Query("pagesize", "50"))

	return models.PosSalesFilter{
		Locationid:  locationID,
		Fromdate:    strings.TrimSpace(c.Query("fromdate")),
		Todate:      strings.TrimSpace(c.Query("todate")),
		Terminalid:  strings.TrimSpace(c.Query("terminalid")),
		Cashiername: strings.TrimSpace(c.Query("cashiername")),
		Paymentmode: strings.TrimSpace(c.Query("paymentmode")),
		Pageno:      pageno,
		Pagesize:    pagesize,
	}, nil
}

// GetSales lists counter bills for an outlet, newest first.
func (ctl *PosController) GetSales(c *fiber.Ctx) error {
	filter, err := posSalesFilter(c)
	if err != nil {
		return posBadRequest(c, err)
	}

	page, err := ctl.posService.Sales(filter)
	if err != nil {
		return posServerError(c, "GetSales", err)
	}

	return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": page})
}

// GetSaleDetail returns one bill with its lines.
//
// Accepts the terminal's order UUID, the invoice number, or this backend's
// posorderid — a support call starts from whichever the caller is looking at.
func (ctl *PosController) GetSaleDetail(c *fiber.Ctx) error {
	locationID, err := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
	if err != nil || locationID <= 0 {
		return posBadRequest(c, fmt.Errorf("locationid is required"))
	}

	reference := strings.TrimSpace(c.Query("reference"))
	if reference == "" {
		return posBadRequest(c, fmt.Errorf("reference is required — an order id, invoice number or posorderid"))
	}

	bill, err := ctl.posService.SaleDetail(locationID, reference)
	if err != nil {
		return posServerError(c, "GetSaleDetail", err)
	}
	if bill == nil {
		return c.Status(http.StatusNotFound).JSON(fiber.Map{
			"code":    http.StatusNotFound,
			"message": "no bill matches that reference at this outlet",
			"status":  false,
		})
	}

	return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": bill})
}

// GetSalesSummary totals a range, split by tender, day and till.
func (ctl *PosController) GetSalesSummary(c *fiber.Ctx) error {
	filter, err := posSalesFilter(c)
	if err != nil {
		return posBadRequest(c, err)
	}

	summary, err := ctl.posService.SalesSummary(filter)
	if err != nil {
		return posServerError(c, "GetSalesSummary", err)
	}

	return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": summary})
}

func posBadRequest(c *fiber.Ctx, err error) error {
	return c.Status(http.StatusBadRequest).JSON(fiber.Map{
		"code": http.StatusBadRequest, "message": err.Error(), "status": false,
	})
}

func posServerError(c *fiber.Ctx, op string, err error) error {
	log.Printf("pos %s: %v", op, err)
	return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
		"code": http.StatusInternalServerError, "message": err.Error(), "status": false,
	})
}

// posIngestError decides whether the terminal should retry.
//
// The distinction matters more than the message does. A misconfigured store id
// will be just as wrong on the next attempt, so it is reported as a 4xx and the
// till halts and shows a person the reason. Anything else might succeed later,
// so it is a 5xx and the bills stay queued.
func posIngestError(c *fiber.Ctx, op string, err error) error {
	log.Printf("pos %s: %v", op, err)

	message := err.Error()
	lower := strings.ToLower(message)

	permanent := strings.Contains(lower, "is not a location id") ||
		strings.Contains(lower, "no outlet is registered") ||
		strings.Contains(lower, "does not belong to tenant") ||
		strings.Contains(lower, "has no products stocked") ||
		strings.Contains(lower, "no applocationid configured")

	status := http.StatusInternalServerError
	if permanent {
		status = http.StatusBadRequest
	}

	return c.Status(status).JSON(fiber.Map{
		"code":    status,
		"message": message,
		"status":  false,
	})
}

// Login signs a terminal in and returns its session.
//
// The one POS route that is deliberately left unauthenticated — it is where a
// token comes from. Everything else on the group sits behind the session this
// issues.
func (ctl *PosController) Login(c *fiber.Ctx) error {
	var req models.PosLoginRequest
	if err := c.BodyParser(&req); err != nil {
		return c.Status(http.StatusBadRequest).JSON(fiber.Map{
			"code": http.StatusBadRequest, "status": false,
			"message": "invalid request body",
		})
	}

	if strings.TrimSpace(req.Authname) == "" && strings.TrimSpace(req.Contactno) == "" {
		return c.Status(http.StatusBadRequest).JSON(fiber.Map{
			"code": http.StatusBadRequest, "status": false,
			"message": "an email or mobile number is required",
		})
	}

	session, err := ctl.posService.Login(req)
	if err != nil {
		// A rejected credential is 401 and says nothing about which half was
		// wrong. Anything else is the deployment's problem, not the caller's,
		// and is logged rather than described down the wire.
		if repositories.PosLoginRejected(err) {
			return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
				"code": http.StatusUnauthorized, "status": false,
				"message": err.Error(),
			})
		}

		log.Printf("pos login (%s): %v", req.Authname, err)
		return c.Status(http.StatusForbidden).JSON(fiber.Map{
			"code": http.StatusForbidden, "status": false, "message": err.Error(),
		})
	}

	return c.JSON(fiber.Map{
		"code": http.StatusOK, "status": true,
		"message": "Login successful",
		"details": session,
	})
}

// Session echoes back who the caller is, per their token.
//
// What a till calls on start-up to find out whether the session it saved
// yesterday is still good, without having to make a real request and interpret
// the failure. Answers 401 through the middleware when it is not.
func (ctl *PosController) Session(c *fiber.Ctx) error {
	claims, ok := middleware.PosClaimsFrom(c)
	if !ok {
		return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
			"code": http.StatusUnauthorized, "status": false,
			"message": "no session token was presented",
		})
	}

	return c.JSON(fiber.Map{
		"code": http.StatusOK, "status": true,
		"details": fiber.Map{
			"user_id":     claims.Userid,
			"tenant_id":   claims.Tenantid,
			"location_id": claims.Locationid,
			"store_id":    strconv.Itoa(claims.Locationid),
			"role_id":     claims.Roleid,
			"terminal_id": claims.Terminalid,
			"expires_at":  time.Unix(claims.Expiresat, 0).UTC().Format(time.RFC3339),
		},
	})
}

// Staff lists who may ring a bill at this terminal's outlet.
//
// Scoped by the caller's own session rather than by a query parameter. A till
// asking "who works here" must not be able to ask on behalf of another shop,
// and the answer carries PINs — so the outlet comes from the token, and a
// request without one is refused whatever POS_AUTH_REQUIRED says.
func (ctl *PosController) Staff(c *fiber.Ctx) error {
	claims, ok := middleware.PosClaimsFrom(c)
	if !ok {
		return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
			"code": http.StatusUnauthorized, "status": false,
			"message": "a session token is required to read staff",
		})
	}

	staff, err := ctl.posService.Staff(claims.Tenantid, claims.Locationid)
	if err != nil {
		return posServerError(c, "Staff", err)
	}

	return c.JSON(fiber.Map{
		"code": http.StatusOK, "status": true,
		"details": models.PosStaffResponse{
			Locationid: claims.Locationid,
			Staff:      staff,
		},
	})
}

// ------------------------------------------------------------- Till staff
//
// A shop runs its own counter. A supervisor creates their cashiers from the
// terminal, and every one of these reads the tenant and outlet from the
// caller's session token rather than from the request — so a supervisor at one
// shop cannot create, edit or list staff at another. That is the same inversion
// that stopped a till naming its own store id, applied to people.

// posManager returns the caller's session, provided they may manage staff.
func posManager(c *fiber.Ctx) (utils.PosClaims, error) {
	claims, ok := middleware.PosClaimsFrom(c)
	if !ok {
		return claims, fiber.NewError(http.StatusUnauthorized,
			"a session token is required")
	}
	if !models.PosRoleCanManageStaff(claims.Roleid) {
		// A cashier signing in on the same terminal must not be able to mint
		// themselves a supervisor.
		return claims, fiber.NewError(http.StatusForbidden,
			"only a supervisor can manage till users")
	}
	return claims, nil
}

// CreatePosUser adds a cashier or supervisor at the caller's outlet.
func (ctl *PosController) CreatePosUser(c *fiber.Ctx) error {
	claims, err := posManager(c)
	if err != nil {
		return posClaimError(c, err)
	}

	var req models.PosUserRequest
	if err := c.BodyParser(&req); err != nil {
		return posBadRequest(c, fmt.Errorf("invalid request body"))
	}

	user, err := ctl.posService.CreateUser(claims.Tenantid, claims.Locationid, claims.Configid, req)
	if err != nil {
		// Every failure here is something the caller can act on — a bad role, a
		// PIN already in use, a name left blank — so it is reported as a 400
		// with the reason rather than logged and hidden behind a 500.
		return posBadRequest(c, err)
	}

	return c.Status(http.StatusCreated).JSON(fiber.Map{
		"code": http.StatusCreated, "status": true,
		"message": "User created", "details": user,
	})
}

// UpdatePosUser edits one of the caller's own till users.
func (ctl *PosController) UpdatePosUser(c *fiber.Ctx) error {
	claims, err := posManager(c)
	if err != nil {
		return posClaimError(c, err)
	}

	var req models.PosUserRequest
	if err := c.BodyParser(&req); err != nil {
		return posBadRequest(c, fmt.Errorf("invalid request body"))
	}

	user, err := ctl.posService.UpdateUser(claims.Tenantid, claims.Locationid, req)
	if err != nil {
		return posBadRequest(c, err)
	}

	return c.JSON(fiber.Map{
		"code": http.StatusOK, "status": true,
		"message": "User updated", "details": user,
	})
}

// ListPosUsers returns the till users at the caller's outlet.
//
// Readable by anyone signed in, not only a supervisor: the terminal needs the
// list to show who is on shift, and a cashier can already see their colleagues
// standing next to them. PINs are the part that matters, and those only go to
// somebody who could set them anyway.
func (ctl *PosController) ListPosUsers(c *fiber.Ctx) error {
	claims, ok := middleware.PosClaimsFrom(c)
	if !ok {
		return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
			"code": http.StatusUnauthorized, "status": false,
			"message": "a session token is required",
		})
	}

	users, err := ctl.posService.ListUsers(
		claims.Tenantid, claims.Locationid,
		strings.EqualFold(c.Query("include_inactive"), "true"),
	)
	if err != nil {
		return posServerError(c, "ListPosUsers", err)
	}

	// A cashier sees who is on shift, not how to sign in as them.
	if !models.PosRoleCanManageStaff(claims.Roleid) {
		for i := range users {
			users[i].Pin = ""
		}
	}

	return c.JSON(fiber.Map{
		"code": http.StatusOK, "status": true,
		"details": fiber.Map{"location_id": claims.Locationid, "users": users},
	})
}

// DeletePosUser retires a till user. Deactivates rather than deletes — bills
// carry the cashier's name and shifts settle against it.
func (ctl *PosController) DeletePosUser(c *fiber.Ctx) error {
	claims, err := posManager(c)
	if err != nil {
		return posClaimError(c, err)
	}

	userID, convErr := strconv.Atoi(strings.TrimSpace(c.Query("user_id")))
	if convErr != nil || userID <= 0 {
		return posBadRequest(c, fmt.Errorf("user_id is required"))
	}
	if userID == claims.Userid {
		// Otherwise the last supervisor at a shop can lock everybody out with
		// one tap, and only we can undo it.
		return posBadRequest(c, fmt.Errorf("you cannot deactivate the account you are signed in as"))
	}

	if err := ctl.posService.DeactivateUser(claims.Tenantid, claims.Locationid, userID); err != nil {
		return posBadRequest(c, err)
	}

	return c.JSON(fiber.Map{
		"code": http.StatusOK, "status": true, "message": "User deactivated",
	})
}

// PinLogin signs somebody in by PIN at a terminal that is already open.
//
// Requires an existing valid session, and that is the whole security model
// here: four digits is ten thousand guesses, which is no barrier at all to an
// anonymous caller. Tying it to a token means a supervisor has already opened
// the terminal with a real password, and the guesses are confined to one
// outlet's own staff.
//
// The new session is minted fresh rather than derived from the presented one,
// so a cashier taking over from a supervisor drops the supervisor's
// permissions instead of inheriting them.
func (ctl *PosController) PinLogin(c *fiber.Ctx) error {
	claims, ok := middleware.PosClaimsFrom(c)
	if !ok {
		return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
			"code": http.StatusUnauthorized, "status": false,
			"message": "sign the terminal in with an email and password before using PIN sign-in",
		})
	}

	var req models.PosLoginRequest
	if err := c.BodyParser(&req); err != nil {
		return posBadRequest(c, fmt.Errorf("invalid request body"))
	}
	if strings.TrimSpace(req.Pin) == "" {
		return posBadRequest(c, fmt.Errorf("a PIN is required"))
	}

	session, err := ctl.posService.LoginWithPin(claims.Tenantid, claims.Locationid, req.Pin)
	if err != nil {
		if repositories.PosLoginRejected(err) {
			return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
				"code": http.StatusUnauthorized, "status": false,
				"message": "that PIN was not recognised",
			})
		}
		return posBadRequest(c, err)
	}

	return c.JSON(fiber.Map{
		"code": http.StatusOK, "status": true,
		"message": "Signed in", "details": session,
	})
}

// posClaimError renders the fiber.Error that posManager returns.
func posClaimError(c *fiber.Ctx, err error) error {
	var fe *fiber.Error
	if errors.As(err, &fe) {
		return c.Status(fe.Code).JSON(fiber.Map{
			"code": fe.Code, "status": false, "message": fe.Message,
		})
	}
	return posServerError(c, "posClaims", err)
}

// ------------------------------------------------------- Till staff, from the web
//
// The same staff management as `/pos/users`, for the console an admin actually
// uses. Deliberately the same service calls underneath rather than a parallel
// implementation: a supervisor created from a browser must be the same thing as
// one created at a counter, and two code paths writing one table is exactly how
// that stops being true.
//
// The difference is where the outlet comes from. A terminal proves it with a
// signed token; the console asserts it, because it has no session of its own.
// So it is verified against the tenant before anything is written — which is
// weaker than a signature, and is why these should move behind the same guard
// once the console can hold a session.

// posWebScope reads and checks the tenant and outlet a console request names.
func (ctl *PosController) posWebScope(tenantID, locationID int) error {
	if tenantID <= 0 {
		return fmt.Errorf("tenantid is required")
	}
	if locationID <= 0 {
		return fmt.Errorf("locationid is required")
	}

	allowed, err := ctl.posService.LocationAllowed(tenantID, locationID)
	if err != nil {
		return fmt.Errorf("could not verify the outlet")
	}
	if !allowed {
		// Not "no such outlet" — that would confirm which ids exist. It did not
		// belong to the tenant asking, and that is all the caller needs.
		return fmt.Errorf("outlet %d does not belong to tenant %d", locationID, tenantID)
	}
	return nil
}

// WebCreatePosUser adds a supervisor or cashier from the console.
func (ctl *PosController) WebCreatePosUser(c *fiber.Ctx) error {
	var req models.PosUserWebRequest
	if err := c.BodyParser(&req); err != nil {
		return posBadRequest(c, fmt.Errorf("invalid request body"))
	}

	if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
		return posBadRequest(c, err)
	}

	// The configid the outlet's other people already use, so a new cashier is
	// visible to the same portal as their colleagues. Asked for rather than
	// derived would mean a console sending a number nobody can look up.
	configID := ctl.posService.ConfigidFor(req.Tenantid)

	user, err := ctl.posService.CreateUser(req.Tenantid, req.Locationid, configID, req.PosUserRequest)
	if err != nil {
		return posBadRequest(c, err)
	}

	return c.Status(http.StatusCreated).JSON(fiber.Map{
		"code": http.StatusCreated, "status": true,
		"message": "User created", "details": user,
	})
}

// WebUpdatePosUser edits one of an outlet's till users from the console.
func (ctl *PosController) WebUpdatePosUser(c *fiber.Ctx) error {
	var req models.PosUserWebRequest
	if err := c.BodyParser(&req); err != nil {
		return posBadRequest(c, fmt.Errorf("invalid request body"))
	}

	if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
		return posBadRequest(c, err)
	}

	user, err := ctl.posService.UpdateUser(req.Tenantid, req.Locationid, req.PosUserRequest)
	if err != nil {
		return posBadRequest(c, err)
	}

	return c.JSON(fiber.Map{
		"code": http.StatusOK, "status": true,
		"message": "User updated", "details": user,
	})
}

// WebListPosUsers lists an outlet's till users for the console.
func (ctl *PosController) WebListPosUsers(c *fiber.Ctx) error {
	tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
	locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))

	if err := ctl.posWebScope(tenantID, locationID); err != nil {
		return posBadRequest(c, err)
	}

	users, err := ctl.posService.ListUsers(tenantID, locationID,
		strings.EqualFold(c.Query("include_inactive"), "true"))
	if err != nil {
		return posServerError(c, "WebListPosUsers", err)
	}

	return c.JSON(fiber.Map{
		"code": http.StatusOK, "status": true,
		"details": fiber.Map{"location_id": locationID, "users": users},
	})
}

// WebDeletePosUser retires a till user from the console.
func (ctl *PosController) WebDeletePosUser(c *fiber.Ctx) error {
	tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
	locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))

	if err := ctl.posWebScope(tenantID, locationID); err != nil {
		return posBadRequest(c, err)
	}

	userID, err := strconv.Atoi(strings.TrimSpace(c.Query("userid")))
	if err != nil || userID <= 0 {
		return posBadRequest(c, fmt.Errorf("userid is required"))
	}

	if err := ctl.posService.DeactivateUser(tenantID, locationID, userID); err != nil {
		return posBadRequest(c, err)
	}

	return c.JSON(fiber.Map{
		"code": http.StatusOK, "status": true, "message": "User deactivated",
	})
}

// WebPosRoles lists the roles a console may offer.
//
// Served rather than hardcoded in the console, because the numbers are this
// backend's business. A console that hardcoded 7 and 8 would be wrong the day
// they change, and would have no way to know.
func (ctl *PosController) WebPosRoles(c *fiber.Ctx) error {
	return c.JSON(fiber.Map{
		"code": http.StatusOK, "status": true,
		"details": []fiber.Map{
			{
				"role_id": models.PosRoleSupervisor, "role": "supervisor",
				"label":       models.PosRoleName(models.PosRoleSupervisor),
				"description": "Runs the terminal and creates counter staff. Also signs into the app.",
			},
			{
				"role_id": models.PosRoleCashier, "role": "cashier",
				"label":       models.PosRoleName(models.PosRoleCashier),
				"description": "Billing only.",
			},
		},
	})
}
