package middlewares

import (
	"bssapp-backend/internal/authz"
	"bssapp-backend/permissions"
	"bytes"
	"database/sql"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"

	"bssapp-backend/auth"

	"github.com/gorilla/mux"
)

/*
AuthzGuardV2
- module+action role permission check (mk_sys_role_permissions)
- optional scope checks (department / piyasa) via intersection:
    user_allowed ∩ role_allowed
- cache with TTL

Expected:
- AuthMiddleware runs before this and sets JWT claims in context.
- claims should contain RoleID and UserID.
*/

// =====================================================
// 🔧 CONFIG / CONSTANTS
// =====================================================

const (
	defaultPermTTL  = 60 * time.Second
	defaultScopeTTL = 30 * time.Second

	maxBodyRead = 1 << 20 // 1MB
)

// =====================================================
// 🧠 CACHE
// =====================================================

type cacheItem struct {
	val     any
	expires time.Time
}

type ttlCache struct {
	mu  sync.RWMutex
	ttl time.Duration
	m   map[string]cacheItem
}

type routeMeta struct {
	module string
	action string
}

var routeMetaCache sync.Map

var routeMetaFallback = map[string]routeMeta{
	"GET /api/product-images":                    {module: "order", action: "view"},
	"POST /api/product-images/batch":             {module: "order", action: "view"},
	"GET /api/product-images/{id}/content":       {module: "order", action: "view"},
	"GET /api/product-stock-query-by-attributes": {module: "order", action: "view"},
}

var userLookupPaths = map[string]struct{}{
	"/api/lookups/roles":       {},
	"/api/lookups/departments": {},
	"/api/lookups/piyasalar":   {},
	"/api/lookups/nebim-users": {},
}

// =====================================================
// 🌍 GLOBAL SCOPE CACHE (for invalidation)
// =====================================================

var globalScopeCache *ttlCache

func newTTLCache(ttl time.Duration) *ttlCache {
	return &ttlCache{
		ttl: ttl,
		m:   make(map[string]cacheItem),
	}
}

func (c *ttlCache) get(key string) (any, bool) {
	now := time.Now()
	c.mu.RLock()
	item, ok := c.m[key]
	c.mu.RUnlock()
	if !ok {
		return nil, false
	}
	if now.After(item.expires) {
		// lazy delete
		c.mu.Lock()
		delete(c.m, key)
		c.mu.Unlock()
		return nil, false
	}
	return item.val, true
}

func (c *ttlCache) set(key string, val any) {
	c.mu.Lock()
	c.m[key] = cacheItem{val: val, expires: time.Now().Add(c.ttl)}
	c.mu.Unlock()
}

// =====================================================
// ✅ MAIN MIDDLEWARE
// =====================================================

type AuthzV2Options struct {
	// If true, scope checks are attempted when scope can be extracted.
	EnableScopeChecks bool

	// If true, when scope is required but cannot be extracted, deny.
	// If false, when scope cannot be extracted, scope check is skipped.
	StrictScope bool

	// Override TTLs (optional)
	PermTTL  time.Duration
	ScopeTTL time.Duration

	// Custom extractors (optional). If nil, built-in extractors are used.
	ExtractDepartmentCodes func(r *http.Request) []string
	ExtractPiyasaCodes     func(r *http.Request) []string

	// Decide whether this request should be treated as scope-sensitive.
	// If nil, built-in heuristic is used.
	IsScopeSensitive func(module string, r *http.Request) bool
}

func AuthzGuardV2(pg *sql.DB, module string, action string) func(http.Handler) http.Handler {
	return AuthzGuardV2WithOptions(pg, module, action, AuthzV2Options{
		EnableScopeChecks: true,
		StrictScope:       false,
	})
}

func AuthzGuardV2WithOptions(pg *sql.DB, module string, action string, opt AuthzV2Options) func(http.Handler) http.Handler {

	permTTL := opt.PermTTL
	if permTTL <= 0 {
		permTTL = defaultPermTTL
	}
	scopeTTL := opt.ScopeTTL
	if scopeTTL <= 0 {
		scopeTTL = defaultScopeTTL
	}

	permCache := newTTLCache(permTTL)
	if globalScopeCache == nil {
		globalScopeCache = newTTLCache(scopeTTL)
	}
	scopeCache := globalScopeCache

	// default extractors
	extractDept := opt.ExtractDepartmentCodes
	if extractDept == nil {
		extractDept = defaultExtractDepartmentCodes
	}
	extractPiy := opt.ExtractPiyasaCodes
	if extractPiy == nil {
		extractPiy = defaultExtractPiyasaCodes
	}

	isScopeSensitive := opt.IsScopeSensitive
	if isScopeSensitive == nil {
		isScopeSensitive = defaultIsScopeSensitive
	}

	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

			// OPTIONS passthrough
			if r.Method == http.MethodOptions {
				w.WriteHeader(http.StatusOK)
				return
			}

			claims, ok := auth.GetClaimsFromContext(r.Context())
			if !ok || claims == nil {
				http.Error(w, "unauthorized", 401)
				return
			}

			userID := claims.ID
			roleCode := claims.RoleCode

			// ADMIN BYPASS
			if claims.IsAdmin() {
				next.ServeHTTP(w, r)
				return
			}

			// resolve role_id from role_code
			roleID, err := cachedRoleID(pg, permCache, roleCode)
			if err != nil {
				log.Println("❌ role resolve error:", err)
				http.Error(w, "forbidden", http.StatusForbidden)
				return
			}

			// --------------------------------------------------
			// 🔐 PERMISSION RESOLUTION (USER > ROLE > DENY)
			// --------------------------------------------------

			permRepo := permissions.NewPermissionRepository(pg)

			allowed := false
			resolved := false // karar verildi mi?

			// --------------------------------------------------
			// 1️⃣ USER OVERRIDE (ÖNCELİK)
			// --------------------------------------------------

			overrides, err := permRepo.GetUserOverrides(userID)
			if err != nil {
				log.Println("❌ override load error:", err)
				http.Error(w, "forbidden", http.StatusForbidden)
				return
			}

			for _, o := range overrides {

				if o.Module == module &&
					o.Action == action {

					log.Printf(
						"🔁 USER OVERRIDE → %s:%s = %v",
						module,
						action,
						o.Allowed,
					)

					allowed = o.Allowed
					resolved = true
					break
				}
			}

			// --------------------------------------------------
			// 2️⃣ ROLE + DEPARTMENT (NEW SYSTEM)
			// --------------------------------------------------

			if !resolved {

				deptCodes := claims.DepartmentCodes

				roleDeptAllowed, err := permRepo.ResolvePermissionChain(
					userID,
					roleID,
					deptCodes,
					module,
					action,
				)

				if err != nil {
					log.Println("❌ perm resolve error:", err)
					http.Error(w, "forbidden", http.StatusForbidden)
					return
				}

				if roleDeptAllowed {

					log.Printf("🆕 ROLE+DEPT → %s:%s = true", module, action)

					allowed = true
					resolved = true

				} else {

					log.Printf("🆕 ROLE+DEPT → %s:%s = false (try legacy)", module, action)
				}
			}
			// --------------------------------------------------
			// 3️⃣ ROLE ONLY (LEGACY FALLBACK)
			// --------------------------------------------------

			if !resolved {

				legacyAllowed, err := cachedRolePermission(
					pg,
					permCache,
					roleID,
					module,
					action,
				)

				if err != nil {
					log.Println("❌ legacy perm error:", err)
					http.Error(w, "forbidden", http.StatusForbidden)
					return
				}

				log.Printf("🕰️ LEGACY ROLE → %s:%s = %v", module, action, legacyAllowed)

				allowed = legacyAllowed
				resolved = true
			}

			// --------------------------------------------------
			// 3️⃣ FINAL DECISION
			// --------------------------------------------------

			if !allowed {

				log.Printf(
					"⛔ ACCESS DENIED user=%d %s:%s path=%s",
					claims.ID,
					module,
					action,
					r.URL.Path,
				)

				http.Error(w, "forbidden", http.StatusForbidden)
				return
			}

			log.Printf(
				"✅ ACCESS OK user=%d %s:%s %s",
				claims.ID,
				module,
				action,
				r.URL.Path,
			)

			// --------------------------------------------------
			// 4️⃣ OPTIONAL SCOPE CHECKS (FINAL - SECURE)
			// --------------------------------------------------

			if opt.EnableScopeChecks && isScopeSensitive(module, r) {

				// 🔹 Request’ten gelenler
				reqDepts := normalizeCodes(extractDept(r))
				reqPiy := normalizeCodes(extractPiy(r))

				ctx := r.Context()

				// 🔹 USER PIYASA (DB’den)
				userPiy, err := authz.GetUserPiyasaCodes(pg, int(userID))
				if err != nil {
					log.Println("❌ piyasa load error:", err)
					http.Error(w, "forbidden", 403)
					return
				}

				userPiy = normalizeCodes(userPiy)

				// ------------------------------------------------
				// ✅ PIYASA INTERSECTION
				// ------------------------------------------------

				var effectivePiy []string

				switch {
				case len(reqPiy) > 0 && len(userPiy) > 0:
					effectivePiy = intersect(reqPiy, userPiy)

				case len(reqPiy) > 0 && len(userPiy) == 0:
					// user piyasa tanımlı değilse request'e güvenme → boş kalsın (StrictScope varsa deny)
					effectivePiy = nil

				case len(reqPiy) == 0 && len(userPiy) > 0:
					effectivePiy = userPiy
				}
				if len(reqPiy) > 0 && len(effectivePiy) == 0 {
					// request piyasa istiyor ama user scope karşılamıyor
					http.Error(w, "forbidden", http.StatusForbidden)
					return
				}

				// ------------------------------------------------
				// ✅ CONTEXT’E YAZ
				// ------------------------------------------------

				if len(reqDepts) > 0 {
					ctx = authz.WithDeptCodes(ctx, reqDepts)
				}

				if len(effectivePiy) > 0 {
					ctx = authz.WithPiyasaCodes(ctx, effectivePiy)
				}

				r = r.WithContext(ctx)

				// ------------------------------------------------
				// ❗ STRICT MODE
				// ------------------------------------------------

				if len(reqDepts) == 0 && len(effectivePiy) == 0 {

					if opt.StrictScope {
						http.Error(w, "forbidden", http.StatusForbidden)
						return
					}

					next.ServeHTTP(w, r)
					return
				}

				// ------------------------------------------------
				// 🔍 DEPARTMENT CHECK
				// ------------------------------------------------

				if len(reqDepts) > 0 {

					okDept, err := cachedDeptIntersectionAny(
						pg,
						scopeCache,
						userID,
						roleID,
						reqDepts,
					)

					if err != nil {
						log.Println("❌ dept scope error:", err)
						http.Error(w, "forbidden", http.StatusForbidden)
						return
					}

					if !okDept {
						http.Error(w, "forbidden", http.StatusForbidden)
						return
					}
				}

				// ------------------------------------------------
				// 🔍 PIYASA CHECK
				// ------------------------------------------------

				if len(effectivePiy) > 0 {

					okPiy, err := cachedPiyasaIntersectionAny(
						pg,
						scopeCache,
						userID,
						roleID,
						effectivePiy,
					)

					if err != nil {
						log.Println("❌ piyasa scope error:", err)
						http.Error(w, "forbidden", http.StatusForbidden)
						return
					}

					if !okPiy {
						http.Error(w, "forbidden", http.StatusForbidden)
						return
					}
				}
			}

			// --------------------------------------------------
			// ✅ ALLOW
			// --------------------------------------------------

			next.ServeHTTP(w, r)
		})
	}
}

// =====================================================
// 🔐 PERMISSION CHECK (mk_sys_role_permissions)
// =====================================================

func cachedRolePermission(pg *sql.DB, c *ttlCache, roleID int64, module, action string) (bool, error) {

	key := "perm|" + itoa(roleID) + "|" + module + "|" + action
	if v, ok := c.get(key); ok {
		return v.(bool), nil
	}

	var allowed bool
	err := pg.QueryRow(`
		SELECT allowed
		FROM mk_sys_role_permissions
		WHERE role_id = $1 AND module_code = $2 AND action = $3
	`, roleID, module, action).Scan(&allowed)

	if err == sql.ErrNoRows {
		c.set(key, false)
		return false, nil
	}
	if err != nil {
		return false, err
	}

	c.set(key, allowed)
	return allowed, nil
}

// =====================================================
// 🧩 SCOPE INTERSECTION
//   user scope ∩ role scope
// =====================================================

func cachedDeptIntersectionAny(pg *sql.DB, c *ttlCache, userID, roleID int64, deptCodes []string) (bool, error) {
	// cache by exact request list (sorted would be ideal; normalizeCodes already stabilizes somewhat)
	key := "deptAny|" + itoa(userID) + "|" + itoa(roleID) + "|" + strings.Join(deptCodes, ",")
	if v, ok := c.get(key); ok {
		return v.(bool), nil
	}

	// ANY match: if request wants multiple codes, allow if at least one is in intersection
	// Intersection query:
	// user departments: dfusr_dprt -> mk_dprt(code)
	// role allowed: dfrole_dprt -> mk_dprt(id) OR directly by id
	okAny := false

	// We do it in a single query with ANY($3)
	var dummy int
	err := pg.QueryRow(`
		SELECT 1
		FROM dfusr_dprt ud
		JOIN mk_dprt d ON d.id = ud.dprt_id
		JOIN dfrole_dprt rd ON rd.dprt_id = ud.dprt_id
		WHERE ud.dfusr_id = $1
		  AND rd.dfrole_id = $2
		  AND ud.is_active = true
		  AND rd.is_allowed = true
		  AND d.code = ANY($3)
		LIMIT 1
	`, userID, roleID, pqArray(deptCodes)).Scan(&dummy)

	if err == sql.ErrNoRows {
		c.set(key, false)
		return false, nil
	}
	if err != nil {
		return false, err
	}

	okAny = true
	c.set(key, okAny)
	return okAny, nil
}

func cachedPiyasaIntersectionAny(pg *sql.DB, c *ttlCache, userID, roleID int64, piyasaCodes []string) (bool, error) {

	key := "piyAny|" + itoa(userID) + "|" + itoa(roleID) + "|" + strings.Join(piyasaCodes, ",")
	if v, ok := c.get(key); ok {
		return v.(bool), nil
	}

	var dummy int
	err := pg.QueryRow(`
        SELECT 1
        FROM dfusr_piyasa up
        LEFT JOIN mk_sales_piy p_code
          ON UPPER(translate(TRIM(p_code.code),  'çğıöşüÇĞİÖŞÜ', 'CGIOSUCGIOSU'))
           = UPPER(translate(TRIM(up.piyasa_code), 'çğıöşüÇĞİÖŞÜ', 'CGIOSUCGIOSU'))
        LEFT JOIN mk_sales_piy p_title
          ON UPPER(translate(TRIM(p_title.title), 'çğıöşüÇĞİÖŞÜ', 'CGIOSUCGIOSU'))
           = UPPER(translate(TRIM(up.piyasa_code), 'çğıöşüÇĞİÖŞÜ', 'CGIOSUCGIOSU'))
        WHERE up.dfusr_id = $1
          AND up.is_allowed = true
          AND UPPER(TRIM(COALESCE(p_code.code, p_title.code, up.piyasa_code))) = ANY($2)
        LIMIT 1
    `, userID, pqArray(piyasaCodes)).Scan(&dummy)

	if err == sql.ErrNoRows {
		c.set(key, false)
		return false, nil
	}
	if err != nil {
		return false, err
	}

	c.set(key, true)
	return true, nil
}

// =====================================================
// 🧲 DEFAULT SCOPE DETECTION
// =====================================================

// defaultIsScopeSensitive decides whether this module likely needs dept/piyasa checks.
// You can tighten/extend later.
func defaultIsScopeSensitive(module string, r *http.Request) bool {
	switch module {
	case "order", "customer", "report", "finance":
		return true
	default:
		return false
	}
}

// =====================================================
// 🔎 DEFAULT EXTRACTORS
// =====================================================

// We try to extract scope from:
//   - query params: dprt, dprt_code, department, department_code, piyasa, piyasa_code, market
//   - headers: X-Department, X-Piyasa
//   - json body fields: department / department_code / dprt_code, piyasa / piyasa_code / market_code
//     (body read is safe: we re-inject the body)
func defaultExtractDepartmentCodes(r *http.Request) []string {
	var out []string

	// query params
	for _, k := range []string{"dprt", "dprt_code", "department", "department_code"} {
		out = append(out, splitCSV(r.URL.Query().Get(k))...)
	}

	// headers
	out = append(out, splitCSV(r.Header.Get("X-Department"))...)

	// JSON body (if any)
	out = append(out, extractFromJSONBody(r, []string{
		"department", "department_code", "dprt", "dprt_code",
	})...)

	return out
}

func defaultExtractPiyasaCodes(r *http.Request) []string {
	var out []string

	for _, k := range []string{"piyasa", "piyasa_code", "market", "market_code"} {
		out = append(out, splitCSV(r.URL.Query().Get(k))...)
	}

	out = append(out, splitCSV(r.Header.Get("X-Piyasa"))...)

	out = append(out, extractFromJSONBody(r, []string{
		"piyasa", "piyasa_code", "market", "market_code", "customer_attribute",
	})...)

	return out
}

func extractFromJSONBody(r *http.Request, keys []string) []string {
	// Only for methods that might have body
	switch r.Method {
	case http.MethodPost, http.MethodPut, http.MethodPatch:
	default:
		return nil
	}

	// read body (and restore)
	raw, err := readBodyAndRestore(r, maxBodyRead)
	if err != nil || len(raw) == 0 {
		return nil
	}

	// try parse object
	var obj map[string]any
	if err := json.Unmarshal(raw, &obj); err != nil {
		return nil
	}

	var out []string
	for _, k := range keys {
		if v, ok := obj[k]; ok {
			switch t := v.(type) {
			case string:
				out = append(out, splitCSV(t)...)
			case []any:
				for _, it := range t {
					if s, ok := it.(string); ok {
						out = append(out, splitCSV(s)...)
					}
				}
			}
		}
	}

	return out
}

func readBodyAndRestore(r *http.Request, limit int64) ([]byte, error) {
	if r.Body == nil {
		return nil, nil
	}

	// Read with limit
	raw, err := io.ReadAll(io.LimitReader(r.Body, limit))
	if err != nil {
		return nil, err
	}

	// restore
	r.Body = io.NopCloser(bytes.NewBuffer(raw))
	return raw, nil
}

// =====================================================
// 🧼 HELPERS
// =====================================================

func splitCSV(s string) []string {
	s = strings.TrimSpace(s)
	if s == "" {
		return nil
	}
	parts := strings.Split(s, ",")
	out := make([]string, 0, len(parts))
	for _, p := range parts {
		p = strings.TrimSpace(p)
		if p != "" {
			out = append(out, p)
		}
	}
	return out
}

func normalizeCodes(in []string) []string {
	if len(in) == 0 {
		return nil
	}
	seen := map[string]struct{}{}
	out := make([]string, 0, len(in))
	for _, s := range in {
		s = strings.ToUpper(strings.TrimSpace(s))
		if s == "" {
			continue
		}
		if _, ok := seen[s]; ok {
			continue
		}
		seen[s] = struct{}{}
		out = append(out, s)
	}
	return out
}

// pqArray: minimal adapter to pass []string as Postgres array.
// If you already use lib/pq, replace this with pq.Array.
// Here we use a simple JSON array -> Postgres can cast text[] from ARRAY[]? Not directly.
// So: YOU SHOULD USE lib/pq in your project. If it's already there, change pqArray() to pq.Array(slice).
//
// For now, we implement as a driver.Value using "{A,B}" format (Postgres text[] literal).
type pgTextArray string

func (a pgTextArray) Value() (any, error) { return string(a), nil }

func pqArray(ss []string) any {
	// produce "{A,B,C}" as text[] literal
	// escape quotes minimally
	if len(ss) == 0 {
		return pgTextArray("{}")
	}
	var b strings.Builder
	b.WriteString("{")
	for i, s := range ss {
		if i > 0 {
			b.WriteString(",")
		}
		s = strings.ReplaceAll(s, `"`, `\"`)
		b.WriteString(`"`)
		b.WriteString(s)
		b.WriteString(`"`)
	}
	b.WriteString("}")
	return pgTextArray(b.String())
}

func itoa(n int64) string {
	return strconv.FormatInt(n, 10)
}

// isolate strconv usage without importing it globally in this snippet
func strconvFormatInt(n int64) string {
	// local minimal
	// NOTE: in real code just import strconv and use strconv.FormatInt(n, 10)
	if n == 0 {
		return "0"
	}
	neg := n < 0
	if neg {
		n = -n
	}
	var buf [32]byte
	i := len(buf)
	for n > 0 {
		i--
		buf[i] = byte('0' + (n % 10))
		n /= 10
	}
	if neg {
		i--
		buf[i] = '-'
	}
	return string(buf[i:])
}

// optional: allow passing scope explicitly from handlers via context (advanced use)
type scopeKey string

// =====================================================
// 🔍 ROLE RESOLVER (code -> id) WITH CACHE
// =====================================================

func cachedRoleID(pg *sql.DB, c *ttlCache, roleCode string) (int64, error) {

	key := "role|" + strings.ToLower(roleCode)

	if v, ok := c.get(key); ok {
		return v.(int64), nil
	}

	var id int64

	err := pg.QueryRow(`
		SELECT id
		FROM dfrole
		WHERE LOWER(code) = LOWER($1)
	`, roleCode).Scan(&id)

	if err != nil {
		return 0, err
	}

	c.set(key, id)
	return id, nil
}

// =====================================================
// 🧹 CACHE INVALIDATION (ADMIN)
// =====================================================

func ClearAuthzScopeCacheForUser(userID int64) {

	// NOTE: this clears ALL scope cache.
	// Simple & safe. Optimize later if needed.

	if globalScopeCache != nil {
		globalScopeCache.mu.Lock()
		defer globalScopeCache.mu.Unlock()

		for k := range globalScopeCache.m {
			if strings.Contains(k, "|"+itoa(userID)+"|") {
				delete(globalScopeCache.m, k)
			}
		}
	}
}

// intersect: A ∩ B
func intersect(a, b []string) []string {

	set := make(map[string]struct{}, len(a))

	for _, v := range a {
		set[v] = struct{}{}
	}

	var out []string

	for _, v := range b {
		if _, ok := set[v]; ok {
			out = append(out, v)
		}
	}

	return out
}

func isUserLookupPath(pathTemplate string) bool {
	_, ok := userLookupPaths[pathTemplate]
	return ok
}

func resolveAnyUserCrudPermission(
	repo *permissions.PermissionRepository,
	userID int64,
	roleID int64,
	departmentCodes []string,
) (bool, error) {
	for _, action := range []string{"view", "insert", "update"} {
		allowed, err := repo.ResolvePermissionChain(
			userID,
			roleID,
			departmentCodes,
			"user",
			action,
		)
		if err != nil {
			return false, err
		}
		if allowed {
			return true, nil
		}
	}
	return false, nil
}

func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler {

	return func(next http.Handler) http.Handler {

		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

			// =====================================================
			// 0️⃣ OPTIONS → PASS (CORS preflight)
			// =====================================================
			if r.Method == http.MethodOptions {
				next.ServeHTTP(w, r)
				return
			}

			// =====================================================
			// 1️⃣ AUTH
			// =====================================================
			claims, ok := auth.GetClaimsFromContext(r.Context())
			if !ok || claims == nil {
				log.Printf(
					"AUTHZ_BY_ROUTE 401 reason=claims_missing method=%s path=%s",
					r.Method,
					r.URL.Path,
				)
				http.Error(w, "unauthorized: token missing or invalid", http.StatusUnauthorized)
				return
			}

			// =====================================================
			// 2️⃣ REAL ROUTE TEMPLATE ( /api/users/{id} )
			// =====================================================
			route := mux.CurrentRoute(r)

			if route == nil {
				log.Printf("❌ AUTHZ: route not resolved: %s %s",
					r.Method, r.URL.Path,
				)

				http.Error(w, "route not resolved", http.StatusForbidden)
				return
			}

			pathTemplate, err := route.GetPathTemplate()
			if err != nil {
				log.Printf("❌ AUTHZ: path template error: %v", err)

				http.Error(w, "route template error", http.StatusForbidden)
				return
			}

			// Password change must be reachable for every authenticated user.
			// This avoids permission deadlocks during forced first-password flow.
			if pathTemplate == "/api/password/change" {
				next.ServeHTTP(w, r)
				return
			}

			// Self permission endpoints are required right after login
			// to hydrate UI permission state for the authenticated user.
			switch pathTemplate {
			case "/api/permissions/routes", "/api/permissions/effective":
				next.ServeHTTP(w, r)
				return
			}

			// =====================================================
			// 3️⃣ ROUTE LOOKUP (path + method) with cache+fallback
			// =====================================================
			var module, action string
			routeKey := r.Method + " " + pathTemplate

			if cached, ok := routeMetaCache.Load(routeKey); ok {
				meta := cached.(routeMeta)
				module, action = meta.module, meta.action
			} else {
				err = pg.QueryRow(`
					SELECT module_code, action
					FROM mk_sys_routes
					WHERE path = $1
					  AND method = $2
				`,
					pathTemplate,
					r.Method,
				).Scan(&module, &action)

				if err == nil {
					routeMetaCache.Store(routeKey, routeMeta{module: module, action: action})
				} else if fb, ok := routeMetaFallback[routeKey]; ok {
					module, action = fb.module, fb.action
					routeMetaCache.Store(routeKey, fb)
					log.Printf("⚠️ AUTHZ: route lookup fallback used: %s", routeKey)
				} else if err == sql.ErrNoRows {
					log.Printf("❌ AUTHZ: route not registered: %s %s", r.Method, pathTemplate)
					http.Error(w, "route permission not found", http.StatusForbidden)
					return
				} else {
					log.Printf("❌ AUTHZ: route lookup db error: %s %s err=%v", r.Method, pathTemplate, err)
					http.Error(w, "permission lookup failed", http.StatusInternalServerError)
					return
				}
			}

			// =====================================================
			// 4️⃣ PERMISSION RESOLVE
			// =====================================================
			repo := permissions.NewPermissionRepository(pg)

			allowed, err := repo.ResolvePermissionChain(
				int64(claims.ID),
				int64(claims.RoleID),
				claims.DepartmentCodes,
				module,
				action,
			)

			if err != nil {

				log.Printf(
					"❌ AUTHZ: resolve error user=%d %s:%s err=%v",
					claims.ID,
					module,
					action,
					err,
				)

				if pathTemplate == "/api/password/change" {
					http.Error(w, "password change permission check failed", http.StatusForbidden)
					return
				}

				http.Error(w, "forbidden", http.StatusForbidden)
				return
			}

			if !allowed && isUserLookupPath(pathTemplate) {
				allowed, err = resolveAnyUserCrudPermission(
					repo,
					int64(claims.ID),
					int64(claims.RoleID),
					claims.DepartmentCodes,
				)
				if err != nil {
					log.Printf(
						"❌ AUTHZ: user lookup fallback resolve error user=%d path=%s err=%v",
						claims.ID,
						pathTemplate,
						err,
					)
					http.Error(w, "forbidden", http.StatusForbidden)
					return
				}
			}

			if !allowed {

				log.Printf(
					"⛔ AUTHZ: denied user=%d %s:%s",
					claims.ID,
					module,
					action,
				)

				if pathTemplate == "/api/password/change" {
					http.Error(w, "password change forbidden: permission denied", http.StatusForbidden)
					return
				}

				http.Error(w, "forbidden", http.StatusForbidden)
				return
			}

			// =====================================================
			// 5️⃣ SCOPE CONTEXT ENRICH (for MSSQL piyasa filters)
			// =====================================================
			if !claims.IsAdmin() {
				userPiy, err := authz.GetUserPiyasaCodes(pg, int(claims.ID))
				if err != nil {
					log.Printf("❌ AUTHZ: user piyasa resolve error user=%d err=%v", claims.ID, err)
					http.Error(w, "forbidden", http.StatusForbidden)
					return
				}

				if len(userPiy) > 0 {
					r = r.WithContext(authz.WithPiyasaCodes(r.Context(), normalizeCodes(userPiy)))
				}
			}

			// =====================================================
			// 6️⃣ PASS
			// =====================================================
			next.ServeHTTP(w, r)
		})
	}
}
