package main

import (
	"bufio"
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"testing"

	"github.com/TaruLab/keluargamart-backend/internal/auth"
)

type routeOperation struct {
	method string
	path   string
}

// gate is one authorization middleware mounted on a route or a route group,
// resolved to a comparable identity. The point of this type is that the gate
// information is ON THE AST NODE NEXT TO THE ROUTE and the original parser
// discarded it — so a route group that lost its RequirePermission, or that
// drifted onto a key nobody holds, was invisible to TestMountedRoutesMatchOpenAPI.
// That test can only say a route EXISTS; nothing said whether it is reachable by
// anyone it should not be.
type gate struct {
	// kind is the constructor: RequirePermission, RequireAnyPermission,
	// RequireApp, RequireAnyApp, ecomGate, ecomOrPermGate, scopedAuth,
	// actLog, idem.Handler, or another Use(...).
	kind string
	// args are the string literals passed to it, in order. Empty for a
	// middleware that takes no key (scopedAuth, actLog).
	args []string
}

// id is the comparable form used by the table and the assertions.
func (g gate) id() string {
	if len(g.args) == 0 {
		return g.kind
	}
	return g.kind + ":" + strings.Join(g.args, "|")
}

// gatedRoute is a mounted route together with the gate stack that applies to
// it: the group's Use(...) chain plus the per-route .With(...) chain, in mount
// order, because order decides which gate runs first.
type gatedRoute struct {
	routeOperation
	gates []gate
}

// gateExpectation is one row of the route→gate table: DATA, not logic. Each
// entry names a path prefix and the gates that MUST appear on every route under
// it. An empty want means "assert nothing about gates here", which is how the
// public and self-authenticated routes are declared rather than left ambiguous.
type gateExpectation struct {
	// path is "METHOD /api/v1/...". Any {...} segment is a wildcard, so the
	// table does not repeat chi's parameter names.
	path string
	// want is a set: any one of these appearing on the route satisfies the
	// entry. RequireAnyPermission legitimately mounts several alternatives and
	// which one a given caller satisfies depends on the caller, so requiring all
	// of them would be wrong.
	want []string
	// intentionallyPublic marks a route that is DELIBERATELY ungated — the
	// Midtrans webhook, whose authenticity is the payload signature verified in
	// the service rather than a session.
	//
	// This is separate from an empty `want` on purpose. An empty want reads as
	// "the row forgot to say", and TestGateTableIsNotVacuous rejects it; a
	// boolean reads as a decision, and it INVERTS the assertion — the row
	// requires that no policy gate is present. That is the useful direction
	// here: if someone later hangs a session gate on the webhook, the test says
	// so, because the webhook's whole design is that it is reachable by Midtrans
	// and nobody else.
	intentionallyPublic bool
	// allow is a set of additional gate ids permitted on the route beyond `want`.
	// It exists so that adding a gate is a deliberate edit here rather than an
	// unremarked one.
	allow []string
	// comment is the reason, kept beside the row because a table of gates
	// without reasons is a table nobody dares edit.
	comment string
}

func TestMountedRoutesMatchOpenAPI(t *testing.T) {
	root := repositoryRoot(t)
	// allGatedRoutes, not a second copy of this file list: the route set and the
	// gate set must come from ONE parse, or a route the OpenAPI test sees could
	// be one the gate test never checks.
	gated := allGatedRoutes(t, root)
	actual := make([]routeOperation, 0, len(gated))
	for _, g := range gated {
		actual = append(actual, g.routeOperation)
	}
	documented := parseOpenAPIPaths(t, filepath.Join(root, "docs", "openapi.yaml"))

	got := routeSet(actual)
	want := routeSet(documented)
	var missing, extra []string
	for op := range want {
		if !got[op] {
			missing = append(missing, op)
		}
	}
	for op := range got {
		if !want[op] {
			extra = append(extra, op)
		}
	}
	sort.Strings(missing)
	sort.Strings(extra)
	if len(missing) != 0 || len(extra) != 0 {
		t.Fatalf("route/OpenAPI mismatch\nmissing: %v\nextra: %v", missing, extra)
	}
}

// gateExpectations is the route→gate table. It is DATA on purpose: the
// assertion below is a set membership test, so adding a route is a row rather
// than a new branch, and a reviewer reads the security policy as a list
// instead of reconstructing it from test code.
//
// Each row is a claim about ONE route, matched on method + path, where
// `{...}` in a path matches a chi param of any name. The table is per-route
// rather than per-prefix deliberately: the routes under one prefix legitimately
// carry DIFFERENT keys (GET /invoices is .view, DELETE /invoices/{id} is
// .delete), so a prefix table would either be too coarse to catch a wrong key
// or would need an exception per route — which is the same data spelled worse.
//
// A row that is wrong is a security decision recorded in the wrong place, so
// the `comment` is part of the row and not decoration.
var gateExpectations = []gateExpectation{
	{path: "GET /api/v1/invoices", want: []string{"RequirePermission:fakturisasi.invoices.view"},
		comment: "the invoice ledger; finance reads it, a till does not"},
	{path: "POST /api/v1/invoices/{id}/pay", want: []string{"RequirePermission:fakturisasi.invoices.pay"},
		comment: "booking a payment is the manual resolution path a stranded-payment operator uses"},
	{path: "GET /api/v1/accounting/ledger", want: []string{"RequirePermission:accounting.ledger.view"},
		comment: "the whole-company financial record; 000103 revoked this from the seven operator roles"},
	{path: "GET /api/v1/accounting/journals", want: []string{"RequirePermission:accounting.journals.view"},
		comment: "the general ledger; same revocation as above"},
	{path: "DELETE /api/v1/accounting/journals/{id}", want: []string{"RequirePermission:accounting.journals.delete"},
		comment: "deleting a draft journal is a finance function; 000082 justified granting it by pointing at the update grant"},
	{path: "GET /api/v1/cashflows", want: []string{"RequirePermission:finance.cashflows.view"},
		comment: "Arus Kas. 000103 deliberately left cashier's grant here pending a business decision."},
	{path: "GET /api/v1/users", want: []string{"RequirePermission:system.users.view"},
		comment: "returns every account's permissions AND effective_permissions (permission-matrix reconnaissance)"},
	{path: "PUT /api/v1/role-permissions", want: []string{"RequireAdmin"},
		comment: "the Role Management write path. PUT here re-applies whatever the UI holds, which is why that screen must be out of service while 000103 is applied."},
	{path: "GET /api/v1/employees", want: []string{"RequirePermission:hr.employees.view"},
		comment: "the route hr_manager 403s on until hr.* is restored by 000103"},
	{path: "POST /api/v1/midtrans/notifications", intentionallyPublic: true,
		comment: "PUBLIC by design: authenticity is the notification signature verified in the service (SHA-512 over order_id+status_code+gross_amount+server_key), not a session. A session gate here would make the route unreachable by Midtrans."},
	{path: "GET /api/v1/inventory/stock", want: []string{"RequireAnyPermission:inventory.stock.view|sales.pos.create"},
		comment: "AVAILABILITY only. sales.sales-orders.view was removed by 000103; cost is gated separately by internal/inventory on inventory.cost.view."},
	{path: "POST /api/v1/pos", want: []string{"RequireAnyApp:auth.AppPOS|auth.AppERP"},
		allow:   []string{"RequirePermission:sales.pos.create"},
		comment: "shared POS surface: the app gate keeps a B2B customer out, and sales.pos.create keeps a reader out of the sale itself"},
	{path: "POST /api/v1/midtrans/sweep", want: []string{"SnapSweepTokenGate"},
		comment: "machine route gated on a shared secret rather than a session: crontab holds no user session, so a session gate would make it uncallable"},
	{path: "GET /api/v1/sales-orders", want: []string{"ecomOrPermGate:sales.sales-orders.view"},
		comment: "the sales desk's order book. ecomOrPermGate admits a storefront identity OR the permission. 000103 revoked the permission from buyer; List() additionally scopes a RoleBuyer caller to their own customer_id, because the storefront admin legitimately holds this key and needs the unfiltered list."},
	{path: "POST /api/v1/payments/{id}/proof", want: []string{"RequireAnyApp:auth.AppECommerce|auth.AppERP"},
		comment: "proof upload is shared by storefront buyers and ERP payment staff; POS-only identities must not enter the ownership handler"},
}

// TestMountedRouteGates is the gate contract. Two independent assertions, both
// over EVERY parsed route rather than only the tabled ones:
//
//  1. CATALOG CROSS-CHECK, every route. A gate naming a permission key the
//     catalog does not contain is a SILENT DENY-ALL: RequirePermission never
//     consults the catalog, so nothing fails at mount time and every caller gets
//     403 forever. TestMountedRoutesMatchOpenAPI cannot see this — the route is
//     mounted and documented.
//
//  2. TABLE, per route. Each tabled route must carry one of its `want` gates,
//     and nothing outside `want`+`allow`. That catches a route that lost its
//     gate, and a route quietly re-gated onto a different key.
//
// The first is deliberately not restricted to the table. The table records the
// policy a reviewer cares about; the cross-check is a machine fact about every
// gate in the router, and restricting it would leave a typo'd key on an
// untabled route invisible.
func TestMountedRouteGates(t *testing.T) {
	root := repositoryRoot(t)
	routes := allGatedRoutes(t, root)
	if len(routes) == 0 {
		t.Fatal("no routes parsed; the gate test would pass vacuously")
	}

	// (1) Every permission key mounted anywhere must be in the catalog.
	for _, r := range routes {
		for _, g := range r.gates {
			key, ok := permissionFromGate(g.id())
			if !ok {
				continue
			}
			if !auth.IsKnownPermission(key) {
				t.Errorf("%s %s: gate %q uses permission %q, which is not in the catalog. "+
					"RequirePermission does not validate at mount time, so this denies every caller silently.",
					r.method, r.path, g.id(), key)
			}
		}
	}
	// (1b) The table's own keys, so a typo fails even before the router mounts
	// that route.
	for _, exp := range gateExpectations {
		for _, want := range exp.want {
			if key, ok := permissionFromGate(want); ok && !auth.IsKnownPermission(key) {
				t.Errorf("table row %q requires %q, which is not in the catalog", exp.path, key)
			}
		}
	}

	// (2) Per-route table assertions.
	for _, exp := range gateExpectations {
		matched := matchRoutes(routes, exp.path)
		if len(matched) == 0 {
			t.Errorf("table row %q matches no mounted route; the table has drifted from the router", exp.path)
			continue
		}
		for _, r := range matched {
			ids := map[string]bool{}
			for _, g := range r.gates {
				ids[g.id()] = true
			}
			if exp.intentionallyPublic {
				// Inverted assertion: a deliberately public route must carry NO
				// policy gate, so a session hung on the webhook is caught.
				for _, g := range r.gates {
					if !gateIsStructural(g.kind) {
						t.Errorf("%s %s is declared intentionally public but mounts gate %q\n  reason: %s",
							r.method, r.path, g.id(), exp.comment)
					}
				}
				continue
			}
			if !anyOf(ids, exp.want) {
				t.Errorf("%s %s: expected one of %v, mounted gates are %v\n  reason: %s",
					r.method, r.path, exp.want, gateIDs(r.gates), exp.comment)
				continue
			}
			permitted := map[string]bool{}
			for _, w := range append(append([]string{}, exp.want...), exp.allow...) {
				permitted[w] = true
			}
			for _, g := range r.gates {
				if gateIsStructural(g.kind) || permitted[g.id()] {
					continue
				}
				t.Errorf("%s %s: gate %q is not in the table's want/allow for %q\n  reason: %s",
					r.method, r.path, g.id(), exp.path, exp.comment)
			}
		}
	}
}

// matchRoutes resolves a table pattern like "GET /api/v1/invoices/{id}/pay"
// against the mounted routes, treating any {...} segment as a wildcard so the
// table does not have to repeat chi's parameter names.
// allGatedRoutes is the single place that decides which files carry mounted
// routes, so the OpenAPI test and the gate test cannot parse different sets and
// quietly disagree about what the router contains. Adding a router file here
// makes both tests see it.
func allGatedRoutes(t *testing.T, root string) []gatedRoute {
	t.Helper()
	routes := parseGatedRoutes(t, filepath.Join(root, "cmd", "server", "main.go"), "")
	routes = append(routes, parseGatedRoutes(t, filepath.Join(root, "internal", "system", "handler.go"), "/api/v1", "RegisterRoutes")...)
	return routes
}

// TestGateTableIsNotVacuous is the check on the CHECKER.
//
// A table-driven test whose table stopped matching the router, or whose
// matching collapsed to "always true", would pass while asserting nothing —
// and a security test that asserts nothing is worse than no test, because it
// reads as coverage. Three independent ways it could go hollow, each pinned
// here against the real parser:
//
//   - the table drifted from main.go and matches nothing (also covered in
//     TestMountedRouteGates, so a drift fails loudly there);
//   - a row's `want` list is empty, so `anyOf` is vacuously true;
//   - a row matches a route that carries NO gates at all, so requiring a gate
//     cannot fail.
//
// The last is the important one: it means the table cannot be satisfied by
// rows that are so coarse they apply to ungated routes.
func TestGateTableIsNotVacuous(t *testing.T) {
	if len(gateExpectations) == 0 {
		t.Fatal("the gate table is empty; TestMountedRouteGates would assert nothing")
	}
	for _, exp := range gateExpectations {
		// A row must assert SOMETHING. An empty want without the deliberate
		// public marker would make anyOf vacuously true; the marker inverts the
		// assertion instead of skipping it.
		if len(exp.want) == 0 && !exp.intentionallyPublic {
			t.Errorf("table row %q has an empty want set and is not marked "+
				"intentionallyPublic: it asserts nothing", exp.path)
		}
		if len(exp.want) > 0 && exp.intentionallyPublic {
			t.Errorf("table row %q is both gated and marked intentionallyPublic", exp.path)
		}
		if exp.comment == "" {
			t.Errorf("table row %q has no reason recorded; a gate table without "+
				"reasons is a table nobody dares edit", exp.path)
		}
	}

	root := repositoryRoot(t)
	routes := allGatedRoutes(t, root)

	// At least one row must match a route that ACTUALLY carries a
	// non-structural gate, so the mechanism is exercised end to end rather
	// than short-circuiting on a route with no policy on it. The public row is
	// excluded: its whole point is that it carries none.
	exercised := 0
	for _, exp := range gateExpectations {
		if exp.intentionallyPublic {
			continue
		}
		for _, r := range matchRoutes(routes, exp.path) {
			for _, g := range r.gates {
				if !gateIsStructural(g.kind) {
					exercised++
					break
				}
			}
		}
	}
	if exercised == 0 {
		t.Error("no table row matched a route carrying a real gate; the table is " +
			"not exercising the parser's gate extraction")
	}

	// And the negative: a route that does not exist must match NOTHING, or the
	// wildcard matching is loose enough to accept any path.
	if got := matchRoutes(routes, "GET /api/v1/there-is-no-such-route"); len(got) != 0 {
		t.Errorf("matchRoutes matched %d routes for a path that does not exist: "+
			"the wildcard matching is too loose", len(got))
	}
}

func matchRoutes(routes []gatedRoute, pattern string) []gatedRoute {
	pm, pp, ok := strings.Cut(pattern, " ")
	if !ok {
		return nil
	}
	want := strings.Split(normalizePath(pp), "/")
	var out []gatedRoute
	for _, r := range routes {
		if r.method != pm {
			continue
		}
		got := strings.Split(normalizePath(r.path), "/")
		if len(got) != len(want) {
			continue
		}
		match := true
		for i := range want {
			if strings.HasPrefix(want[i], "{") && strings.HasSuffix(want[i], "}") {
				continue
			}
			if got[i] != want[i] {
				match = false
				break
			}
		}
		if match {
			out = append(out, r)
		}
	}
	return out
}

func anyOf(have map[string]bool, want []string) bool {
	for _, w := range want {
		if have[w] {
			return true
		}
	}
	return false
}

func gateIDs(gs []gate) []string {
	out := make([]string, 0, len(gs))
	for _, g := range gs {
		out = append(out, g.id())
	}
	return out
}

// gateIsStructural reports whether a gate is cross-cutting plumbing rather than
// an authorization decision, matched on the CONSTRUCTOR name only.
//
// It takes the kind, not the id, on purpose. BodyLimit(cfg.BodySizeLimitBytes)
// and CORS(cfg.CORSOrigins) are constructors with a configuration argument, and
// comparing the whole id would mean restating every config value in this
// function — a list that would need editing the next time a middleware gained
// an option. The constructor name is the stable part and the part that says
// whether it is policy.
//
// None of these decides WHO may see WHAT, so requiring them per-route in the
// table would be noise and would bury the rows that are actual policy.
func gateIsStructural(kind string) bool {
	switch kind {
	// Authentication of the caller, audit, replay protection, rate limiting,
	// transport concerns.
	case "scopedAuth", "optAuth", "actLog", "Handler", "RequestID", "Logging", "Recovery",
		"SecurityHeaders", "BodyLimit", "CORS", "Compress", "Timeout", "Otel", "RealIP":
		return true
	default:
		return false
	}
}

// permissionFromGate extracts the single permission key a gate names, for the
// catalog cross-check. It handles the single-key constructors only; a
// RequireAnyPermission entry is checked by the caller listing each key
// separately, and anything else returns ok=false so the check is skipped rather
// than guessed at.
func permissionFromGate(id string) (string, bool) {
	for _, p := range []string{"RequirePermission:", "RequireAnyPermission:", "ecomGate:", "ecomOrPermGate:"} {
		if !strings.HasPrefix(id, p) {
			continue
		}
		rest := strings.TrimPrefix(id, p)
		first, _, _ := strings.Cut(rest, "|")
		// A RequireAnyApp argument (auth.AppPOS) is not a permission key even
		// though it is dotted, so the catalog lookup is restricted to the
		// constructors above rather than attempted on any dotted argument.
		if first == "" || !strings.Contains(first, ".") {
			return "", false
		}
		return first, true
	}
	return "", false
}

func parseMountedRoutes(t *testing.T, filename, basePrefix string, functionNames ...string) []routeOperation {
	gated := parseGatedRoutes(t, filename, basePrefix, functionNames...)
	out := make([]routeOperation, 0, len(gated))
	for _, g := range gated {
		out = append(out, g.routeOperation)
	}
	return out
}

// parseGatedRoutes is parseMountedRoutes plus the gate stack. The two share
// the traversal on purpose: a second, near-identical walker would be a second
// place for the route set and the gate set to disagree, and then the OpenAPI
// test would pass on a route the gate test never saw.
//
// The gate stack is threaded through the recursion, which is what makes it a
// STACK rather than a set: `sr.Use(x)` inside a group applies to every route
// mounted after it in that group, and `sr.With(a).Get(...)` applies to that one
// route. Carrying the accumulated gates down through visitBlock is the same
// model chi uses, so the test reads the same way the router does.
func parseGatedRoutes(t *testing.T, filename, basePrefix string, functionNames ...string) []gatedRoute {
	t.Helper()
	fset := token.NewFileSet()
	file, err := parser.ParseFile(fset, filename, nil, 0)
	if err != nil {
		t.Fatalf("parse %s: %v", filename, err)
	}
	allowed := map[string]bool{}
	for _, name := range functionNames {
		allowed[name] = true
	}
	var routes []gatedRoute
	// visitBlock walks one function body, carrying the prefix and the gates
	// accumulated by enclosing Use(...) calls. useShadows collects the gates
	// from statements that apply to a nested group, since a group is mounted
	// after its parent's Use calls have run.
	var visitBlock func(*ast.BlockStmt, string, []gate)
	visitBlock = func(block *ast.BlockStmt, prefix string, inherited []gate) {
		// pending is the group's own Use(...) chain, in mount order.
		pending := append([]gate(nil), inherited...)
		for _, stmt := range block.List {
			// A statement-level `r.Use(g)` extends the chain for the rest of
			// this group. It must be read BEFORE the route walk, because chi
			// applies middleware in mount order and a Use after a Get does not
			// protect that Get.
			if use, ok := useCall(stmt); ok {
				pending = append(pending, use...)
				continue
			}
			ast.Inspect(stmt, func(node ast.Node) bool {
				call, ok := node.(*ast.CallExpr)
				if !ok {
					return true
				}
				selector, ok := call.Fun.(*ast.SelectorExpr)
				if !ok {
					return true
				}
				if selector.Sel.Name == "Route" && len(call.Args) == 2 {
					path, ok := stringLiteral(call.Args[0])
					fn, fnOK := call.Args[1].(*ast.FuncLit)
					if ok && fnOK {
						// Group(...) and With(...).Group(...) wrappers apply their
						// gates to the nested group, so they are collected too.
						nested := pending
						nested = append(nested, wrapperGates(call.Fun)...)
						visitBlock(fn.Body, joinRoute(prefix, path), nested)
						return false
					}
				}
				method := strings.ToUpper(selector.Sel.Name)
				if isHTTPMethod(method) && len(call.Args) >= 1 {
					if path, ok := stringLiteral(call.Args[0]); ok {
						full := joinRoute(prefix, path)
						if !strings.HasPrefix(full, "/docs") && full != "/openapi.yaml" &&
							!strings.HasPrefix(full, "/api/v1/docs") && full != "/api/v1/openapi.yaml" {
							gs := append([]gate(nil), pending...)
							gs = append(gs, wrapperGates(call.Fun)...)
							routes = append(routes, gatedRoute{
								routeOperation: routeOperation{method: method, path: full},
								gates:          gs,
							})
						}
					}
				}
				return true
			})
		}
	}

	ast.Inspect(file, func(node ast.Node) bool {
		fn, ok := node.(*ast.FuncDecl)
		if !ok || (len(allowed) > 0 && !allowed[fn.Name.Name]) || (len(allowed) == 0 && fn.Name.Name != "router") {
			return true
		}
		visitBlock(fn.Body, basePrefix, nil)
		return false
	})
	return routes
}

// useCall recognises a statement-level `r.Use(a, b)` and returns its gates.
func useCall(stmt ast.Stmt) ([]gate, bool) {
	expr, ok := stmt.(*ast.ExprStmt)
	if !ok {
		return nil, false
	}
	call, ok := expr.X.(*ast.CallExpr)
	if !ok {
		return nil, false
	}
	sel, ok := call.Fun.(*ast.SelectorExpr)
	if !ok || sel.Sel.Name != "Use" {
		return nil, false
	}
	return gatesOf(call.Args), true
}

// wrapperGates reads the `.With(...)` chain wrapping an HTTP method call, which
// is where a per-route gate lives: `sr.With(a, b).Get("/", h)`. Walking the Fun
// chain rather than only the outermost selector is what catches a
// `With(idem, RequirePermission(x))` where the permission is not last.
func wrapperGates(fun ast.Expr) []gate {
	var out []gate
	// The chain is `recv.With(a, b).Get(...)`: fun arrives as the OUTER
	// SelectorExpr (X = the With call, Sel = Get), so descend through the
	// selector first and then keep walking the call chain.
	if sel, ok := fun.(*ast.SelectorExpr); ok {
		fun = sel.X
	}
	for {
		call, ok := fun.(*ast.CallExpr)
		if !ok {
			return out
		}
		sel, ok := call.Fun.(*ast.SelectorExpr)
		if !ok {
			return out
		}
		if sel.Sel.Name == "With" {
			// With(...) is the OUTERMOST link, so prepend to keep mount order
			// (a later-collected link is a deeper one).
			out = append(gatesOf(call.Args), out...)
		}
		// Any other link — the HTTP method itself, Group, Route — is a pass
		// through to the receiver below it.
		fun = sel.X
	}
}

// exprName renders a non-literal middleware ARGUMENT by name, so an app
// constant like auth.AppPOS is comparable in the table. Selector and ident
// expressions are the only forms main.go uses for arguments; anything else
// (a composite literal, a call) yields "" and is dropped, because a gate's
// identity is its constructor plus its keys, not the shape of an argument that
// is neither.
//
// It is deliberately NOT applied to a bare middleware VALUE (idem.Handler,
// scopedAuth): those take no arguments and their identity is the constructor
// alone, so rendering the receiver there would make the same gate read
// differently depending on which variable mounted it.
func exprName(e ast.Expr) string {
	switch v := e.(type) {
	case *ast.Ident:
		return v.Name
	case *ast.SelectorExpr:
		if x, ok := v.X.(*ast.Ident); ok {
			return x.Name + "." + v.Sel.Name
		}
		return v.Sel.Name
	default:
		return ""
	}
}

func gatesOf(args []ast.Expr) []gate {
	out := make([]gate, 0, len(args))
	for _, a := range args {
		switch e := a.(type) {
		case *ast.CallExpr:
			// The CONSTRUCTOR name only, not the receiver. main.go spells the
			// same middleware two ways — auth.RequirePermission(...) and the
			// bare RequireApp(...) from a dot-import — and a table that had to
			// name both would be a table nobody could read. The receiver says
			// which router variable, not which gate.
			//
			// A local helper (ecomOrPermGate("x"), defined in main.go rather
			// than imported) arrives as an Ident rather than a SelectorExpr, and
			// it is exactly the kind of gate a table must be able to name.
			var kind string
			switch f := e.Fun.(type) {
			case *ast.SelectorExpr:
				kind = f.Sel.Name
			case *ast.Ident:
				kind = f.Name
			default:
				continue
			}
			g := gate{kind: kind}
			for _, arg := range e.Args {
				// String literals are permission keys. Anything else is an app
				// constant (auth.AppPOS), rendered by name so RequireAnyApp's
				// alternatives are visible in the table rather than collapsed
				// into a bare "RequireAnyApp" that says nothing.
				if s, ok := stringLiteral(arg); ok {
					g.args = append(g.args, s)
				} else if s := exprName(arg); s != "" {
					g.args = append(g.args, s)
				}
			}
			out = append(out, g)
		case *ast.SelectorExpr:
			// A bare middleware VALUE (idem.Handler, scopedAuth): no arguments,
			// so the constructor is the whole identity.
			out = append(out, gate{kind: e.Sel.Name})
		case *ast.Ident:
			out = append(out, gate{kind: e.Name})
		}
	}
	return out
}

func parseOpenAPIPaths(t *testing.T, filename string) []routeOperation {
	t.Helper()
	f, err := os.Open(filename)
	if err != nil {
		t.Fatal(err)
	}
	defer f.Close()
	var routes []routeOperation
	var path string
	inPaths := false
	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		line := strings.TrimRight(scanner.Text(), "\r")
		if line == "paths:" {
			inPaths = true
			continue
		}
		if !inPaths {
			continue
		}
		if line != "" && line[0] != ' ' && line[0] != '#' {
			break
		}
		if strings.HasPrefix(line, "  /") && strings.HasSuffix(line, ":") {
			path = strings.TrimSuffix(strings.TrimSpace(line), ":")
			continue
		}
		if path != "" && strings.HasPrefix(line, "    ") && !strings.HasPrefix(line, "     ") && strings.HasSuffix(line, ":") {
			method := strings.ToUpper(strings.TrimSuffix(strings.TrimSpace(line), ":"))
			if isHTTPMethod(method) {
				routes = append(routes, routeOperation{method: method, path: path})
			}
		}
	}
	if err := scanner.Err(); err != nil {
		t.Fatal(err)
	}
	return routes
}

func routeSet(routes []routeOperation) map[string]bool {
	set := make(map[string]bool, len(routes))
	for _, route := range routes {
		set[route.method+" "+normalizePath(route.path)] = true
	}
	return set
}

func joinRoute(prefix, path string) string {
	joined := strings.TrimSuffix(prefix, "/") + "/" + strings.TrimPrefix(path, "/")
	return normalizePath(joined)
}

func normalizePath(path string) string {
	path = "/" + strings.Trim(path, "/")
	if path == "//" {
		return "/"
	}
	return path
}

func stringLiteral(expr ast.Expr) (string, bool) {
	lit, ok := expr.(*ast.BasicLit)
	if !ok || lit.Kind.String() != "STRING" {
		return "", false
	}
	return strings.Trim(lit.Value, "\"`"), true
}

func isHTTPMethod(method string) bool {
	switch method {
	case "GET", "POST", "PUT", "PATCH", "DELETE":
		return true
	default:
		return false
	}
}

func repositoryRoot(t *testing.T) string {
	t.Helper()
	wd, err := os.Getwd()
	if err != nil {
		t.Fatal(err)
	}
	for dir := wd; ; dir = filepath.Dir(dir) {
		if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
			return dir
		}
		parent := filepath.Dir(dir)
		if parent == dir {
			t.Fatal(fmt.Errorf("repository root not found from %s", wd))
		}
	}
}
