package middleware

import (
	"encoding/json"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"

	"nearle/services"
	"nearle/utils"

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

// Authorisation for the POS terminal.
//
// Before this, the whole POS surface was open. A till named its own outlet on
// the wire and was believed, so `store_id=1185` in a URL was enough to read
// another tenant's catalogue or post bills into their books. There was no
// middleware in the codebase at all and the `JWT_SECRET_KEY` in the config was
// read and never used.
//
// The fix is two checks, in this order:
//
//  1. the caller holds a token this server signed, and
//  2. the outlet they are naming belongs to the tenant inside that token.
//
// The second is the one that matters. A valid token is not a licence to name
// any location — it is a licence to name *your* locations, and without the
// cross-check a real terminal at one shop could still read the shop next door.

// PosLocalsKey names where the verified claims are parked for handlers.
const PosLocalsKey = "posclaims"

// posAuthRequired reports whether a request without a valid token is refused.
//
// Defaults to OFF, and that is a deliberate, temporary choice rather than an
// oversight. Terminals are already in shops billing real customers against the
// unauthenticated endpoints; switching enforcement on at deploy would stop
// every one of them mid-trade. So the endpoint ships first, tills adopt it, and
// `POS_AUTH_REQUIRED=true` closes the door once the fleet is carrying tokens.
//
// While it is off a token is still *verified* when one is sent, and a request
// carrying a token for the wrong tenant is still refused — the flag only
// decides what happens to a request carrying none.
func posAuthRequired() bool {
	return strings.EqualFold(strings.TrimSpace(os.Getenv("POS_AUTH_REQUIRED")), "true")
}

// PosAuth verifies the session token and pins the request to its outlet.
func PosAuth(pos services.PosService) fiber.Handler {
	return func(c *fiber.Ctx) error {
		token := bearerToken(c)

		if token == "" {
			if posAuthRequired() {
				return posUnauthorized(c, "a session token is required; sign in at /pos/login")
			}
			// Legacy till. Allowed through un-pinned, which is exactly the state
			// this middleware exists to end — see posAuthRequired.
			return c.Next()
		}

		claims, err := utils.ParsePosToken(token, time.Now())
		if err != nil {
			// Always refused, flag or no flag. A token that does not verify is
			// a stronger signal than no token at all: nothing sends a broken
			// one by accident.
			return posUnauthorized(c, err.Error())
		}

		// The outlet named in the request, if it named one. Every POS route
		// spells this differently — `store_id` on catalogue, `locationid` on
		// sales, `location_id` on health — so all three are read rather than
		// the caller being asked to change.
		requested := requestedLocation(c)

		if requested > 0 && requested != claims.Locationid {
			// A different outlet than the token was issued for. Permitted only
			// if the tenant genuinely owns it — a proprietor with six shops
			// should be able to look at all six from one signed-in session.
			allowed, err := pos.LocationAllowed(claims.Tenantid, requested)
			if err != nil {
				return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
					"code": http.StatusServiceUnavailable, "status": false,
					"message": "could not verify outlet access",
				})
			}
			if !allowed {
				return c.Status(http.StatusForbidden).JSON(fiber.Map{
					"code": http.StatusForbidden, "status": false,
					"message": "this session cannot reach outlet " + strconv.Itoa(requested),
				})
			}
		}

		c.Locals(PosLocalsKey, claims)
		return c.Next()
	}
}

// bearerToken reads the session out of the request.
//
// `Authorization: Bearer …` is the form to use. `X-Pos-Token` is accepted as
// well because some of the shop routers between a till and this server strip
// Authorization headers on plain HTTP, and a terminal that cannot authenticate
// is a shop that cannot trade.
func bearerToken(c *fiber.Ctx) string {
	header := strings.TrimSpace(c.Get("Authorization"))
	if header != "" {
		if after, found := strings.CutPrefix(header, "Bearer "); found {
			return strings.TrimSpace(after)
		}
		if !strings.Contains(header, " ") {
			// Tolerates a bare token. Terminals in the field get this wrong and
			// the alternative is a shop that cannot sell.
			return header
		}
	}
	return strings.TrimSpace(c.Get("X-Pos-Token"))
}

// requestedLocation reads whichever outlet parameter this route happens to use.
//
// The three spellings are a wart — `store_id`, `locationid` and `location_id`
// all mean the same thing across the POS routes. Normalising them is a breaking
// change for terminals already in the field, so this reads all three instead
// and leaves the naming alone.
//
// The body is searched as well as the query, and that is not an optional extra:
// the two routes that *write* — order and customer ingest — carry `store_id` in
// a JSON batch and never in the URL. Checking only the query string would leave
// the exact call that posts bills into another tenant's books unguarded, which
// is the hole this middleware exists to close.
func requestedLocation(c *fiber.Ctx) int {
	for _, key := range []string{"store_id", "locationid", "location_id"} {
		if raw := strings.TrimSpace(c.Query(key)); raw != "" {
			if id, err := strconv.Atoi(raw); err == nil && id > 0 {
				return id
			}
		}
	}
	return bodyLocation(c)
}

// bodyLocation pulls the outlet out of a JSON request body.
//
// Decoded into a loose map rather than the batch type on purpose. This runs
// before the handler and must not reject anything the handler would have
// accepted — a body that fails to parse here is left to the handler to refuse
// with its own message, and a batch shape that changes later must not silently
// stop being authorised.
//
// `c.Body()` returns the buffered bytes, so reading it here does not consume
// the stream the handler goes on to parse.
func bodyLocation(c *fiber.Ctx) int {
	body := c.Body()
	if len(body) == 0 || len(body) > 8<<20 {
		return 0
	}

	var probe struct {
		Storeid    json.RawMessage `json:"store_id"`
		Locationid json.RawMessage `json:"location_id"`
	}
	if err := json.Unmarshal(body, &probe); err != nil {
		return 0
	}

	for _, raw := range []json.RawMessage{probe.Storeid, probe.Locationid} {
		if id := asLocationID(raw); id > 0 {
			return id
		}
	}
	return 0
}

// asLocationID reads an id that may have been sent as a number or as a string.
//
// The till sends `"store_id": "1135"` and the health payload sends
// `"location_id": "1135"`, both quoted, while other callers send it bare.
// Accepting only one shape would silently skip the check for the other — and a
// skipped check here reads exactly like a passed one.
func asLocationID(raw json.RawMessage) int {
	if len(raw) == 0 {
		return 0
	}

	var asString string
	if err := json.Unmarshal(raw, &asString); err == nil {
		if id, err := strconv.Atoi(strings.TrimSpace(asString)); err == nil && id > 0 {
			return id
		}
		return 0
	}

	var asNumber int
	if err := json.Unmarshal(raw, &asNumber); err == nil && asNumber > 0 {
		return asNumber
	}
	return 0
}

func posUnauthorized(c *fiber.Ctx, message string) error {
	return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
		"code": http.StatusUnauthorized, "status": false, "message": message,
	})
}

// PosClaimsFrom returns the verified session on a request, if it carried one.
//
// The second return distinguishes "no token" from "a token claiming tenant 0",
// which a caller acting on the tenant id must not confuse.
func PosClaimsFrom(c *fiber.Ctx) (utils.PosClaims, bool) {
	claims, ok := c.Locals(PosLocalsKey).(utils.PosClaims)
	return claims, ok
}
