package system

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"regexp"
	"strings"
	"time"

	"github.com/jackc/pgx/v5/pgtype"
	"github.com/shopspring/decimal"
	"golang.org/x/crypto/bcrypt"

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

// Sentinel errors mapped to HTTP in the handler.
var (
	ErrEmailTaken        = errors.New("email already registered")
	ErrInvalidRole       = errors.New("invalid role")
	ErrShortPassword     = errors.New("password must be at least 8 characters")
	ErrSelfDelete        = errors.New("you cannot delete your own account")
	ErrSelfEscalation    = errors.New("you cannot change your own role or active status")
	ErrLastAdmin         = errors.New("cannot demote, deactivate or delete the last active admin")
	ErrInvalidEmail      = errors.New("invalid email")
	ErrNothingToDo       = errors.New("nothing to update")
	ErrRoleNameRequired  = errors.New("role name is required")
	ErrRoleNotAssignable = errors.New("you may not assign that role")
	ErrBuiltinRole       = errors.New("builtin role")
	ErrInvalidRoleName   = errors.New("invalid role name")
	ErrRoleExists        = errors.New("role already exists")

	// Multi-tenant §6: store management + role permission matrix.
	ErrStoreFieldsRequired  = errors.New("store code and name are required")
	ErrStoreInvalidLocation = errors.New("latitude must be between -90 and 90, longitude between -180 and 180")
	ErrPermissionRequired   = errors.New("permission is required")
	ErrUnknownPermission    = errors.New("unknown permission")
	// StoreScope hardening (authz audit 2026-09-09): a store-bound manager
	// (kepala toko) may only read/edit staff their own outlet(s).
	ErrStoreOutOfScope = errors.New("store is outside your outlet scope")
)

// Service implements system/audit use cases: user management (password hashed
// with bcrypt, parity with auth), roles (decorative rows) and the audit trail.
type Service struct {
	store          Store
	loc            *time.Location
	onMatrixChange func()
	notifProvider  func(ctx context.Context, u *auth.User) ([]NotificationItem, error)
	// tokenRevoker revokes every session of a user. Wired from main to the
	// auth repository (R2-6): an admin-initiated password change must end
	// the target's sessions the way a self-service change already does.
	// Nil = feature off (tests).
	tokenRevoker TokenRevoker
}

// TokenRevoker ends every session of a user.
type TokenRevoker func(ctx context.Context, userID int64) error

// WithTokenRevoker wires session revocation for admin password changes.
func (s *Service) WithTokenRevoker(fn TokenRevoker) *Service {
	if fn != nil {
		s.tokenRevoker = fn
	}
	return s
}

func NewService(store Store, loc *time.Location) *Service {
	return &Service{store: store, loc: loc}
}

func (s *Service) WithNotificationProvider(fn func(ctx context.Context, u *auth.User) ([]NotificationItem, error)) *Service {
	s.notifProvider = fn
	return s
}

func (s *Service) ListNotifications(ctx context.Context, u *auth.User) ([]NotificationItem, error) {
	if s.notifProvider != nil {
		return s.notifProvider(ctx, u)
	}
	return []NotificationItem{}, nil
}

// --- users -----------------------------------------------------------------

// tenantStores returns the caller's tenant store ids, or nil when the caller is
// platform-wide (admin/GM). A kepala toko is store-bound, so their user list
// and every user mutation is limited to their own outlet's accounts.
func tenantStores(ctx context.Context) []int64 {
	sc := auth.StoreScopeFrom(ctx)
	if sc.PlatformWide() {
		return nil
	}
	return sc.IDs()
}

// requireUserInScope returns auth.ErrNotFound when a store-bound caller targets
// a user outside its outlet(s) — 404, so the API never leaks that the id exists.
func (s *Service) requireUserInScope(ctx context.Context, id int64) error {
	ids := tenantStores(ctx)
	if ids == nil {
		return nil
	}
	ok, err := s.store.UserInStores(ctx, id, ids)
	if err != nil {
		return err
	}
	if !ok {
		return auth.ErrNotFound
	}
	return nil
}

func (s *Service) ListUsers(ctx context.Context, page, perPage int, filter UserFilter) ([]auth.User, int64, ModuleCounts, error) {
	ids := tenantStores(ctx)
	if ids == nil {
		return s.store.ListUsers(ctx, page, perPage, filter)
	}
	// A store-bound caller (kepala toko) only sees, in its outlet, the roles it
	// may actually manage (cashier) — plus its own row. Other managers or an
	// admin that happens to be bound to the same store are hidden.
	actor := auth.UserFrom(ctx)
	var roles []string
	var selfID int64
	if actor != nil {
		for _, r := range assignableBy[actor.Role] {
			roles = append(roles, string(r))
		}
		selfID = actor.ID
	}
	return s.store.ListUsersInStores(ctx, ids, roles, selfID, page, perPage, filter)
}

func (s *Service) UserByID(ctx context.Context, id int64) (*auth.User, error) {
	if err := s.requireUserInScope(ctx, id); err != nil {
		return nil, err
	}
	return s.store.UserByID(ctx, id)
}

func (s *Service) CreateUser(ctx context.Context, in UserInput) (*auth.User, error) {
	if err := validateUserInput(in); err != nil {
		return nil, err
	}
	if !s.roleValid(ctx, auth.Role(in.Role)) {
		return nil, ErrInvalidRole
	}
	if err := guardAssignableRole(ctx, auth.Role(in.Role)); err != nil {
		return nil, err
	}
	if err := guardPermissionsGrant(ctx, in.Permissions); err != nil {
		return nil, err
	}
	if err := rejectUnknownPermissions(in.Permissions); err != nil {
		return nil, err
	}
	if in.Password == "" {
		return nil, ErrShortPassword
	}
	if auth.WeakPassword(in.Password) {
		return nil, errors.New("password is too common or too simple to be allowed")
	}
	if _, err := s.store.UserByEmail(ctx, in.Email); err == nil {
		return nil, ErrEmailTaken
	} else if !errors.Is(err, auth.ErrNotFound) {
		return nil, err
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
	if err != nil {
		return nil, err
	}
	active := true
	if in.IsActive != nil {
		active = *in.IsActive
	}
	created, err := s.store.CreateUser(ctx, &auth.User{
		Name:        in.Name,
		Email:       in.Email,
		Role:        auth.Role(in.Role),
		Permissions: toPermissions(in.Permissions),
		IsActive:    active,
	}, string(hash))
	if err != nil {
		return nil, err
	}
	// A store-bound creator (kepala toko) can only mint a cashier
	// (guardAssignableRole); bind it to the creator's outlet(s) so it is
	// tenant-scoped like the rest of the outlet's staff.
	for _, sid := range tenantStores(ctx) {
		if e := s.store.AddStoreUser(ctx, sid, created.ID); e != nil {
			return nil, e
		}
	}
	return created, nil
}

func (s *Service) UpdateUser(ctx context.Context, id, actorID int64, in UserInput) (*auth.User, error) {
	if err := s.requireUserInScope(ctx, id); err != nil {
		return nil, err
	}
	existing, err := s.store.UserByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if in.Name == "" && in.Email == "" && in.Role == "" && in.Password == "" && in.IsActive == nil && in.Permissions == nil {
		return nil, ErrNothingToDo
	}
	// Self-service guard: an admin editing their own row may not change their
	// role or active status (prevents self-escalation and self-lockout).
	if id == actorID && (in.Role != "" || in.IsActive != nil) {
		return nil, ErrSelfEscalation
	}
	// A non-platform actor may only move a user BETWEEN roles it could have
	// assigned in the first place — both the new role and the existing one,
	// otherwise a store manager could take over an admin account by demoting
	if err := guardAssignableRole(ctx, auth.Role(in.Role)); err != nil {
		return nil, err
	}
	if err := guardPermissionsGrant(ctx, in.Permissions); err != nil {
		return nil, err
	}
	if err := rejectUnknownPermissions(in.Permissions); err != nil {
		return nil, err
	}
	// A non-platform actor may only edit a user whose role it could have
	// assigned in the first place — regardless of which fields the request
	// touches. Guarding only role/password/active edits would let a store
	// manager take over an admin account with an email-only edit (the
	// forgot-password flow completes the takeover).
	if err := guardAssignableRole(ctx, existing.Role); err != nil {
		return nil, err
	}
	// validate provided fields
	up := *existing
	if in.Name != "" {
		up.Name = in.Name
	}
	if in.Email != "" {
		up.Email = in.Email
	}
	if in.Role != "" {
		up.Role = auth.Role(in.Role)
	}
	if !s.roleValid(ctx, up.Role) {
		return nil, ErrInvalidRole
	}
	if !validEmail(up.Email) {
		return nil, ErrInvalidEmail
	}
	if in.Email != "" && in.Email != existing.Email {
		if _, err := s.store.UserByEmail(ctx, in.Email); err == nil {
			return nil, ErrEmailTaken
		} else if !errors.Is(err, auth.ErrNotFound) {
			return nil, err
		}
	}
	// Last-admin guard: do not demote or deactivate the final active admin.
	if existing.Role == auth.RoleAdmin && existing.IsActive {
		removing := in.Role != "" && auth.Role(in.Role) != auth.RoleAdmin
		deactivating := in.IsActive != nil && !*in.IsActive
		if removing || deactivating {
			n, err := s.store.CountActiveAdmins(ctx)
			if err != nil {
				return nil, err
			}
			if n <= 1 {
				return nil, ErrLastAdmin
			}
		}
	}
	if in.Permissions != nil {
		up.Permissions = toPermissions(in.Permissions)
	}
	if in.IsActive != nil {
		up.IsActive = *in.IsActive
	}
	var pwHash *string
	if in.Password != "" {
		if len(in.Password) < 8 {
			return nil, ErrShortPassword
		}
		// Same policy as self-service profile changes: an admin must not be
		// able to bypass the denylist by setting passwords on behalf of a user.
		if auth.WeakPassword(in.Password) {
			return nil, errors.New("password is too common or too simple to be allowed")
		}
		hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
		if err != nil {
			return nil, err
		}
		hs := string(hash)
		pwHash = &hs
	}
	out, err := s.store.UpdateUser(ctx, &up, pwHash)
	if err != nil {
		return nil, err
	}
	if pwHash != nil && s.tokenRevoker != nil {
		// The credential changed under the target: end every session so a
		// stolen token cannot outlive it (R2-6b). Revocation is idempotent,
		// so a retry after a partial failure stays safe.
		if err := s.tokenRevoker(ctx, id); err != nil {
			return nil, err
		}
	}
	return out, nil
}

func (s *Service) DeleteUser(ctx context.Context, id, actorID int64) error {
	if id == actorID {
		return ErrSelfDelete
	}
	if err := s.requireUserInScope(ctx, id); err != nil {
		return err
	}
	existing, err := s.store.UserByID(ctx, id)
	if err != nil {
		return err
	}
	// Defence in depth: system.users.delete is platform-only today, but the
	// permission matrix is editable at runtime.
	if err := guardAssignableRole(ctx, existing.Role); err != nil {
		return err
	}
	if existing.Role == auth.RoleAdmin && existing.IsActive {
		n, err := s.store.CountActiveAdmins(ctx)
		if err != nil {
			return err
		}
		if n <= 1 {
			return ErrLastAdmin
		}
	}
	return s.store.DeleteUser(ctx, id)
}

func (s *Service) ToggleUserActive(ctx context.Context, id, actorID int64) (*auth.User, error) {
	if id == actorID {
		return nil, ErrSelfEscalation
	}
	if err := s.requireUserInScope(ctx, id); err != nil {
		return nil, err
	}
	u, err := s.store.UserByID(ctx, id)
	if err != nil {
		return nil, err
	}
	// Same reach limit as UpdateUser: a store manager holds system.users.update
	// (which gates this route) and must not be able to lock out an admin.
	if err := guardAssignableRole(ctx, u.Role); err != nil {
		return nil, err
	}
	if u.Role == auth.RoleAdmin && u.IsActive {
		n, err := s.store.CountActiveAdmins(ctx)
		if err != nil {
			return nil, err
		}
		if n <= 1 {
			return nil, ErrLastAdmin
		}
	}
	if err := s.store.SetUserActive(ctx, id, !u.IsActive); err != nil {
		return nil, err
	}
	return s.store.UserByID(ctx, id)
}

func (s *Service) ListRoles(ctx context.Context) ([]RoleResponse, error) {
	roles, err := s.store.ListRoles(ctx)
	if err != nil {
		return nil, err
	}
	out := make([]RoleResponse, 0, len(roles))
	for _, r := range roles {
		out = append(out, RoleResponse{
			ID: r.ID, Name: r.Name,
			CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
			Builtin: isBuiltinRole(r.Name),
		})
	}
	return out, nil
}

func (s *Service) CreateRole(ctx context.Context, in RoleInput) (db.Role, error) {
	if err := validateNewRoleName(in.Name); err != nil {
		return db.Role{}, err
	}
	if err := s.rejectDuplicateRole(ctx, in.Name); err != nil {
		return db.Role{}, err
	}
	return s.store.CreateRole(ctx, in.Name)
}

func (s *Service) UpdateRole(ctx context.Context, id int64, in RoleInput) (db.Role, error) {
	existing, err := s.store.RoleByID(ctx, id)
	if err != nil {
		return db.Role{}, err
	}
	if isBuiltinRole(existing.Name) {
		return db.Role{}, fmt.Errorf("role %q is a builtin role and cannot be renamed: %w", existing.Name, ErrBuiltinRole)
	}
	if err := validateNewRoleName(in.Name); err != nil {
		return db.Role{}, err
	}
	if in.Name == existing.Name {
		return existing, nil
	}
	if err := s.rejectDuplicateRole(ctx, in.Name); err != nil {
		return db.Role{}, err
	}
	return s.store.RenameRole(ctx, id, existing.Name, in.Name)
}

func (s *Service) DeleteRole(ctx context.Context, id int64) error {
	existing, err := s.store.RoleByID(ctx, id)
	if err != nil {
		return err
	}
	if isBuiltinRole(existing.Name) {
		return fmt.Errorf("role %q is a builtin role and cannot be deleted: %w", existing.Name, ErrBuiltinRole)
	}
	return s.store.DeleteRoleTx(ctx, id, existing.Name)
}

// --- activity logs ---------------------------------------------------------

func (s *Service) ListLogs(ctx context.Context, q LogQuery, page, perPage int) ([]ActivityLog, int64, error) {
	rows, total, err := s.store.ListLogs(ctx, q, page, perPage)
	if err != nil {
		return nil, 0, err
	}
	// Group row ids by model type so display names resolve in one query per
	// type instead of one per row.
	byType := map[string][]int64{}
	for _, r := range rows {
		if r.ModelID.Valid && r.ModelType.Valid {
			t := r.ModelType.String
			byType[t] = append(byType[t], r.ModelID.Int64)
		}
	}
	labels := map[string]map[int64]string{}
	for t, ids := range byType {
		m, err := s.store.ObjectLabels(ctx, t, ids)
		if err != nil {
			return nil, 0, err
		}
		labels[t] = m
	}
	out := make([]ActivityLog, 0, len(rows))
	for _, r := range rows {
		v := activityLogView(r)
		if r.ModelID.Valid && r.ModelType.Valid {
			if l, ok := labels[r.ModelType.String][r.ModelID.Int64]; ok {
				v.ObjectLabel = &l
			}
		}
		out = append(out, v)
	}
	return out, total, nil
}

func (s *Service) ClearLogs(ctx context.Context) error {
	return s.store.ClearLogs(ctx)
}

func activityLogView(r db.ListActivityLogsRow) ActivityLog {
	return ActivityLog{
		ID:          r.ID,
		UserID:      int8Ptr(r.UserID),
		Module:      textPtr(r.Module),
		Action:      textPtr(r.Action),
		Description: textPtr(r.Description),
		ModelType:   textPtr(r.ModelType),
		ModelID:     int8Ptr(r.ModelID),
		OldValues:   raw(r.OldValues),
		NewValues:   raw(r.NewValues),
		IPAddress:   textPtr(r.IpAddress),
		UserAgent:   textPtr(r.UserAgent),
		CreatedAt:   r.CreatedAt.Time,
		UpdatedAt:   r.UpdatedAt.Time,
		UserName:    r.UserName,
	}
}

func int8Ptr(v pgtype.Int8) *int64 {
	if !v.Valid {
		return nil
	}
	x := v.Int64
	return &x
}

func int8(v int64) pgtype.Int8 {
	return pgtype.Int8{Int64: v, Valid: true}
}

func int8FromPtr(v *int64) pgtype.Int8 {
	if v == nil {
		return pgtype.Int8{}
	}
	return pgtype.Int8{Int64: *v, Valid: true}
}

func str(v *string) pgtype.Text {
	if v == nil {
		return pgtype.Text{}
	}
	return pgtype.Text{String: *v, Valid: true}
}

func raw(b []byte) json.RawMessage {
	if len(b) == 0 {
		return nil
	}
	return json.RawMessage(b)
}

// --- helpers ---------------------------------------------------------------

func validateUserInput(in UserInput) error {
	if strings.TrimSpace(in.Name) == "" {
		return errors.New("name is required")
	}
	if strings.TrimSpace(in.Email) == "" {
		return errors.New("email is required")
	}
	if !validEmail(in.Email) {
		return ErrInvalidEmail
	}
	// Role validity (builtin OR roles registry) needs the store, so it is
	// checked by the caller via roleValid — here only require a value.
	if in.Role == "" {
		return ErrInvalidRole
	}
	if in.Password != "" && len(in.Password) < 8 {
		return ErrShortPassword
	}
	return nil
}

// assignableBy answers "may this actor put that role on a user account?".
//
// system.users.create used to be an all-or-nothing key: whoever held it could
// mint any role, admin included. That was tolerable while only platform
// identities held it. Migration 000044 gives it to store_manager so a kepala
// toko can staff their own outlet, which turns the missing check into a
// privilege-escalation path: create an admin, log in as it, own the tenant.
//
// Rule: platform identities (admin/owner) keep the unrestricted behaviour;
// every other actor may only assign roles from an explicit allowlist. A role
// with no entry here can only be assigned by a platform identity.
var assignableBy = map[auth.Role][]auth.Role{
	auth.RoleStoreManager: {auth.RoleCashier, auth.RoleEmployee},
	auth.RoleGM:           {auth.RoleStoreManager, auth.RoleCashier, auth.RoleBM, auth.RoleSPV, auth.RoleSales},
	auth.RoleBM:           {auth.RoleStoreManager, auth.RoleCashier, auth.RoleSPV, auth.RoleSales},
}

// guardAssignableRole rejects a role the authenticated actor may not hand out.
// The actor comes from the request context (auth.Middleware); an absent actor
// means the call did not pass authentication, so it is refused rather than
// waved through.
func guardAssignableRole(ctx context.Context, role auth.Role) error {
	if role == "" {
		return nil // caller is not changing the role
	}
	actor := auth.UserFrom(ctx)
	if actor == nil {
		return ErrRoleNotAssignable
	}
	if actor.Role == auth.RoleAdmin || actor.Role == auth.RoleOwner {
		return nil
	}
	for _, allowed := range assignableBy[actor.Role] {
		if allowed == role {
			return nil
		}
	}
	return ErrRoleNotAssignable
}

// guardPermissionsGrant stops grant-escalation through users.permissions:
// a non-platform actor may only pass nil, empty, or all-false overrides
// (deny-only). Any true grant is refused — otherwise a store manager could
// hand a cashier accounting.*/system.* it could never assign as a role,
// or widen its own row the same way.
func guardPermissionsGrant(ctx context.Context, perms map[string]bool) error {
	if len(perms) == 0 {
		return nil
	}
	actor := auth.UserFrom(ctx)
	if actor == nil {
		return ErrRoleNotAssignable
	}
	if actor.Role == auth.RoleAdmin || actor.Role == auth.RoleOwner {
		return nil
	}
	for _, v := range perms {
		if v {
			return ErrRoleNotAssignable
		}
	}
	return nil
}

// rejectUnknownPermissions refuses override keys the catalog does not cover
// (exact key or catalog-covering glob), so neither role rows nor user
// overrides can silently store dead rows. The error names the bad key.
func rejectUnknownPermissions(perms map[string]bool) error {
	if len(perms) == 0 {
		return nil
	}
	keys := make([]string, 0, len(perms))
	for k := range perms {
		keys = append(keys, k)
	}
	if bad := auth.FirstUnknownKey(keys); bad != "" {
		return fmt.Errorf("%w %q", ErrUnknownPermission, bad)
	}
	return nil
}

func validRole(r auth.Role) bool {
	for _, role := range auth.AllRoles {
		if role == r {
			return true
		}
	}
	return false
}

func isBuiltinRole(name string) bool {
	return validRole(auth.Role(name))
}

// roleValid accepts builtin roles plus any name present in the roles registry.
// Fail-closed: a store error reads as invalid rather than waving through.
func (s *Service) roleValid(ctx context.Context, r auth.Role) bool {
	if validRole(r) {
		return true
	}
	_, err := s.store.RoleByName(ctx, string(r))
	return err == nil
}

var roleSlugRe = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)

// validateNewRoleName enforces the custom-role name rules: non-empty, not a
// builtin name, slug-shaped and at most 32 chars.
func validateNewRoleName(name string) error {
	if strings.TrimSpace(name) == "" {
		return ErrRoleNameRequired
	}
	if isBuiltinRole(name) {
		return fmt.Errorf("role %q is a builtin role: %w", name, ErrBuiltinRole)
	}
	if len(name) > 32 || !roleSlugRe.MatchString(name) {
		return fmt.Errorf("role %q must match ^[a-z][a-z0-9_]*$ and be at most 32 characters: %w", name, ErrInvalidRoleName)
	}
	return nil
}

func (s *Service) rejectDuplicateRole(ctx context.Context, name string) error {
	if _, err := s.store.RoleByName(ctx, name); err == nil {
		return fmt.Errorf("role %q already exists: %w", name, ErrRoleExists)
	} else if !errors.Is(err, auth.ErrNotFound) {
		return err
	}
	return nil
}

func validEmail(email string) bool {
	// Lightweight check: a non-empty local part, a single @ and a dot domain.
	i := strings.IndexByte(email, '@')
	if i <= 0 || i == len(email)-1 {
		return false
	}
	return strings.Contains(email[i+1:], ".")
}

func toPermissions(in map[string]bool) auth.Permissions {
	if in == nil {
		return nil
	}
	m := auth.Permissions{}
	for k, v := range in {
		m[k] = v
	}
	return m
}

// --- tenant stores & role-permission matrix (multi-tenant §6) ---------------

// OnMatrixChange is invoked after every role_permissions mutation so the host
// can hot-reload the auth matrix (auth.SetRoleMatrix). Set via
// SetOnMatrixChange; safe to leave nil.
func (s *Service) SetOnMatrixChange(fn func()) { s.onMatrixChange = fn }

func (s *Service) notifyMatrix() {
	if s.onMatrixChange != nil {
		s.onMatrixChange()
	}
}

func textOpt(s *string) pgtype.Text {
	if s == nil || strings.TrimSpace(*s) == "" {
		return pgtype.Text{}
	}
	return pgtype.Text{String: *s, Valid: true}
}

// nullDecimal maps an optional coordinate to a nullable numeric (nil → SQL NULL).
func nullDecimal(d *decimal.Decimal) decimal.NullDecimal {
	if d == nil {
		return decimal.NullDecimal{}
	}
	return decimal.NullDecimal{Decimal: *d, Valid: true}
}

func validateStoreInput(in StoreInput) error {
	if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
		return ErrStoreFieldsRequired
	}
	if in.Latitude != nil && (in.Latitude.LessThan(decimal.NewFromInt(-90)) || in.Latitude.GreaterThan(decimal.NewFromInt(90))) {
		return ErrStoreInvalidLocation
	}
	if in.Longitude != nil && (in.Longitude.LessThan(decimal.NewFromInt(-180)) || in.Longitude.GreaterThan(decimal.NewFromInt(180))) {
		return ErrStoreInvalidLocation
	}
	return nil
}

// requireStoreInScope rejects store-bound callers (kepala toko) touching an
// outlet outside their own — 403, unlike users (404): the store id space is
// small and public within the ERP, so scope denial is explicit, not hidden.
// Platform-wide callers (admin/owner, scope middleware yields nil) pass.
func requireStoreInScope(ctx context.Context, id int64) error {
	sc := auth.StoreScopeFrom(ctx)
	if sc.PlatformWide() || sc.AllowsID(id) {
		return nil
	}
	return ErrStoreOutOfScope
}

func (s *Service) ListStores(ctx context.Context) ([]db.Store, error) {
	stores, err := s.store.ListStores(ctx)
	if err != nil {
		return nil, err
	}
	// StoreScope hardening: a store-bound manager only sees their own
	// outlet(s); platform-wide callers keep the full directory.
	ids := tenantStores(ctx)
	if ids == nil {
		return stores, nil
	}
	allowed := make(map[int64]bool, len(ids))
	for _, id := range ids {
		allowed[id] = true
	}
	scoped := make([]db.Store, 0, len(stores))
	for _, st := range stores {
		if allowed[st.ID] {
			scoped = append(scoped, st)
		}
	}
	return scoped, nil
}

func (s *Service) GetStore(ctx context.Context, id int64) (db.Store, error) {
	if err := requireStoreInScope(ctx, id); err != nil {
		return db.Store{}, err
	}
	return s.store.StoreByID(ctx, id)
}

func (s *Service) CreateStore(ctx context.Context, in StoreInput) (db.Store, error) {
	if err := validateStoreInput(in); err != nil {
		return db.Store{}, err
	}
	active := in.IsActive == nil || *in.IsActive
	sp := db.CreateStoreParams{
		Code: in.Code, Name: in.Name, Address: textOpt(in.Address), Phone: textOpt(in.Phone), IsActive: active,
		Latitude: nullDecimal(in.Latitude), Longitude: nullDecimal(in.Longitude),
	}

	// Every outlet owns exactly one warehouse. When the caller does not name an
	// existing one, provision `WH-<code>` alongside the store in one tx so the
	// POS warehouse lookup (and stock scoping) resolves without extra setup.
	var wh *db.CreateWarehouseParams
	if in.WarehouseID != nil && *in.WarehouseID > 0 {
		sp.WarehouseID = *in.WarehouseID
	} else {
		wh = &db.CreateWarehouseParams{
			Code:     "WH-" + strings.TrimSpace(in.Code),
			Name:     truncate(strings.TrimSpace(in.Name)+" - Gudang", 255),
			Address:  textOpt(in.Address),
			IsActive: true,
		}
	}

	store, err := s.store.CreateStoreWithWarehouse(ctx, sp, wh)
	if err != nil {
		return db.Store{}, err
	}
	return store, nil
}

func truncate(s string, n int) string {
	if len(s) <= n {
		return s
	}
	return s[:n]
}

func (s *Service) UpdateStore(ctx context.Context, id int64, in StoreInput) (db.Store, error) {
	if err := requireStoreInScope(ctx, id); err != nil {
		return db.Store{}, err
	}
	if err := validateStoreInput(in); err != nil {
		return db.Store{}, err
	}
	existing, err := s.store.StoreByID(ctx, id)
	if err != nil {
		return db.Store{}, err
	}
	isActive := existing.IsActive
	if in.IsActive != nil {
		isActive = *in.IsActive
	}
	return s.store.UpdateStore(ctx, db.UpdateStoreParams{
		ID: id, Code: in.Code, Name: in.Name, Address: textOpt(in.Address), Phone: textOpt(in.Phone), IsActive: isActive,
		Latitude: nullDecimal(in.Latitude), Longitude: nullDecimal(in.Longitude),
	})
}

func (s *Service) DeleteStore(ctx context.Context, id int64) error {
	st, err := s.store.StoreByID(ctx, id)
	if err != nil {
		return err
	}
	if err := s.store.DeleteStore(ctx, id); err != nil {
		return err
	}
	// Retire the outlet's warehouse when no store is left behind it. Without
	// this, deleting the last store of an outlet left its warehouse active,
	// and checkout pickers (B2B + POS read active-only options) kept offering
	// a hub with no store behind it.
	if st.WarehouseID == 0 {
		return nil
	}
	whID := st.WarehouseID
	remaining, err := s.store.CountStoresByWarehouse(ctx, whID)
	if err != nil {
		return err
	}
	if remaining > 0 {
		return nil
	}
	// Used means stock on hand or any transactional footprint: deactivate so
	// pickers hide it while history stays intact. Unused means safe to remove.
	used, err := s.store.WarehouseHasInventory(ctx, whID)
	if err != nil {
		return err
	}
	if used {
		return s.store.DeactivateWarehouse(ctx, whID)
	}
	return s.store.DeleteWarehouse(ctx, whID)
}

func (s *Service) ListStoreUsers(ctx context.Context, storeID int64) ([]db.ListStoreUsersRow, error) {
	if err := requireStoreInScope(ctx, storeID); err != nil {
		return nil, err
	}
	if _, err := s.store.StoreByID(ctx, storeID); err != nil {
		return nil, err
	}
	return s.store.ListStoreUsers(ctx, storeID)
}

func (s *Service) AddStoreUser(ctx context.Context, storeID, userID int64) error {
	if err := requireStoreInScope(ctx, storeID); err != nil {
		return err
	}
	if _, err := s.store.StoreByID(ctx, storeID); err != nil {
		return err
	}
	target, err := s.store.UserByID(ctx, userID)
	if err != nil {
		return err
	}
	// A store-bound manager must not bind a higher-privileged account (e.g.
	// an admin) into their own outlet — that would manufacture the in-scope
	// precondition for editing that account via UpdateUser.
	if err := guardAssignableRole(ctx, target.Role); err != nil {
		return err
	}
	return s.store.AddStoreUser(ctx, storeID, userID)
}

func (s *Service) RemoveStoreUser(ctx context.Context, storeID, userID int64) error {
	if err := requireStoreInScope(ctx, storeID); err != nil {
		return err
	}
	n, err := s.store.RemoveStoreUser(ctx, storeID, userID)
	if err != nil {
		return err
	}
	if n == 0 {
		return auth.ErrNotFound
	}
	return nil
}

// RolePermissionMatrix returns the stored rows plus the fully resolved
// allow-set per role (the DB row set, or deny-all when the role has zero
// rows; admin/owner keep the "*" wildcard parity publish).
func (s *Service) RolePermissionMatrix(ctx context.Context) (RoleMatrixView, error) {
	rows, err := s.store.RolePermissionMatrix(ctx, nil)
	if err != nil {
		return RoleMatrixView{}, err
	}
	roles, err := s.store.ListRoles(ctx)
	if err != nil {
		return RoleMatrixView{}, err
	}
	byRole := make(map[string][]string, len(rows))
	for _, row := range rows {
		byRole[row.Role] = append(byRole[row.Role], row.Permission)
	}
	names := make(map[string]bool, len(byRole)+len(roles)+len(auth.AllRoles))
	for _, r := range auth.AllRoles {
		names[string(r)] = true
	}
	for _, r := range roles {
		names[r.Name] = true
	}
	for _, row := range rows {
		names[row.Role] = true
	}
	effective := make(map[string]map[string]bool, len(names))
	for name := range names {
		perms, ok := byRole[name]
		effective[name] = auth.EffectiveRoleSet(auth.Role(name), perms, ok)
	}
	if rows == nil {
		rows = []db.ListRolePermissionsRow{}
	}
	return RoleMatrixView{Rows: rows, Effective: effective}, nil
}

// SetRolePermission grants (allow=true) or revokes one permission on a role,
// then notifies the host so the live RBAC matrix reloads.
//
// WRITE-RESULT AUDIT 2026-09-27: the revoke path used to discard the result of
// three independent writes with no transaction around them. A failing
// remediation read skipped the whole shadowing-wildcard fix silently; a failing
// sibling upsert still let the wildcard row go, silently stripping the role of
// every other permission under that prefix; and the wildcard delete discarded
// both its error and its row count, so a surviving sales.* row kept granting the
// permission the operator had just revoked. All three now share one
// transaction: they land together or not at all.
func (s *Service) SetRolePermission(ctx context.Context, roleName, permission string, allow bool) error {
	if !s.roleValid(ctx, auth.Role(roleName)) {
		return ErrInvalidRole
	}
	if strings.TrimSpace(permission) == "" {
		return ErrPermissionRequired
	}
	if !auth.IsKnownPermission(permission) {
		return fmt.Errorf("%w %q", ErrUnknownPermission, permission)
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return err
	}
	defer tx.Rollback(ctx)
	if err := s.setRolePermissionTx(ctx, tx, roleName, permission, allow); err != nil {
		return err
	}
	if err := tx.Commit(ctx); err != nil {
		return err
	}
	s.notifyMatrix()
	return nil
}

// setRolePermissionTx applies the grant/revoke and, on a revoke, the
// shadowing-wildcard remediation, all inside the caller's transaction. Every
// store result is checked: an unreadable matrix means the remediation cannot be
// attempted, so the revoke is rolled back rather than left shadowed.
func (s *Service) setRolePermissionTx(ctx context.Context, tx Tx, roleName, permission string, allow bool) error {
	if allow {
		return s.store.UpsertRolePermission(ctx, tx, roleName, permission)
	}
	if _, err := s.store.DeleteRolePermission(ctx, tx, roleName, permission); err != nil {
		return err
	}
	// If there are any wildcard rows for this role that cover this permission,
	// expand those wildcards into concrete keys excluding this permission,
	// and remove the wildcard row so the revocation is not shadowed.
	rows, err := s.store.RolePermissionMatrix(ctx, tx)
	if err != nil {
		return fmt.Errorf("read role-permission matrix for %q remediation: %w", roleName, err)
	}
	for _, r := range rows {
		if r.Role != roleName || !strings.HasSuffix(r.Permission, ".*") {
			continue
		}
		prefix := strings.TrimSuffix(r.Permission, ".*")
		if !strings.HasPrefix(permission, prefix+".") {
			continue
		}
		for _, g := range auth.Catalog().Groups {
			for _, k := range g.Keys {
				if strings.HasPrefix(k.Key, prefix+".") && k.Key != permission {
					// A grant that fails here must abort: the wildcard row below
					// is what still grants this key, so continuing would revoke
					// far more than the operator asked for.
					if err := s.store.UpsertRolePermission(ctx, tx, roleName, k.Key); err != nil {
						return fmt.Errorf("expand wildcard %q into %q: %w", r.Permission, k.Key, err)
					}
				}
			}
		}
		n, err := s.store.DeleteRolePermission(ctx, tx, roleName, r.Permission)
		if err != nil {
			return fmt.Errorf("delete shadowing wildcard %q from role %q: %w", r.Permission, roleName, err)
		}
		observeWildcardDelete(ctx, roleName, r.Permission, n)
	}
	return nil
}

// observeWildcardDelete surfaces a zero-row wildcard delete at WARN, following
// the token-revocation precedent in internal/auth/repository.go. Zero is not
// proven wrong — the role may hold no wildcard row at all — so it stays a
// successful revocation; ids and the permission key only, never anything else.
func observeWildcardDelete(ctx context.Context, role, permission string, deleted int64) {
	if deleted == 0 {
		logger.FromContext(ctx).Warn("wildcard role-permission delete matched zero rows; the revocation may still be shadowed",
			"role", role, "permission", permission, "deleted_rows", deleted)
	}
}
