package system

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"golang.org/x/crypto/bcrypt"

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

type fakeStore struct {
	users      map[int64]*auth.User
	byEmail    map[string]*auth.User
	nextUserID int64
	roles      map[int64]db.Role
	nextRoleID int64
	logs       []db.ActivityLog
	deleted    map[int64]bool
	activated  map[int64]bool
	lastHash   string
	stores     []db.Store
	// Warehouse retirement fixtures: storeRows feeds StoreByID, storesByWh
	// answers CountStoresByWarehouse, whUsed answers WarehouseHasInventory,
	// and the two slices record what DeleteStore retired.
	storeRows   map[int64]db.Store
	storesByWh  map[int64]int64
	whUsed      map[int64]bool
	deactivated []int64
	deletedWh   []int64
}

func newFakeStore() *fakeStore {
	admin := &auth.User{ID: 1, Name: "Admin", Email: "admin@example.com", Role: auth.RoleAdmin, IsActive: true}
	return &fakeStore{
		users:      map[int64]*auth.User{1: admin},
		byEmail:    map[string]*auth.User{"admin@example.com": admin},
		nextUserID: 2,
		roles:      map[int64]db.Role{1: {ID: 1, Name: "admin"}},
		nextRoleID: 2,
		deleted:    map[int64]bool{},
		activated:  map[int64]bool{},
	}
}

func (f *fakeStore) ListUsers(_ context.Context, _, _ int, filter UserFilter) ([]auth.User, int64, ModuleCounts, error) {
	out := []auth.User{}
	var counts ModuleCounts
	for _, u := range f.users {
		counts.All++
		switch u.Role {
		case auth.RoleCashier:
			counts.POS++
		case auth.RoleBuyer:
			counts.B2B++
		default:
			counts.ERP++
		}

		if filter.Search != "" && !strings.Contains(u.Name, filter.Search) && !strings.Contains(u.Email, filter.Search) {
			continue
		}
		if filter.Role != "" && string(u.Role) != filter.Role {
			continue
		}
		if filter.Status != "" {
			if filter.Status == "active" && !u.IsActive {
				continue
			}
			if (filter.Status == "blocked" || filter.Status == "inactive") && u.IsActive {
				continue
			}
		}
		if filter.Module != "" && filter.Module != "all" {
			mod := strings.ToLower(filter.Module)
			if mod == "pos" && u.Role != auth.RoleCashier {
				continue
			}
			if (mod == "b2b" || mod == "ecommerce") && u.Role != auth.RoleBuyer {
				continue
			}
			if (mod == "erp" || mod == "backoffice") && (u.Role == auth.RoleCashier || u.Role == auth.RoleBuyer) {
				continue
			}
		}
		out = append(out, *u)
	}
	return out, int64(len(out)), counts, nil
}

func (f *fakeStore) ListUsersInStores(ctx context.Context, _ []int64, _ []string, _ int64, page, perPage int, filter UserFilter) ([]auth.User, int64, ModuleCounts, error) {
	return f.ListUsers(ctx, page, perPage, filter)
}

func (f *fakeStore) UserInStores(_ context.Context, _ int64, _ []int64) (bool, error) {
	return true, nil
}

func (f *fakeStore) UserByID(_ context.Context, id int64) (*auth.User, error) {
	if u, ok := f.users[id]; ok {
		return u, nil
	}
	return nil, auth.ErrNotFound
}

func (f *fakeStore) UserByEmail(_ context.Context, email string) (*auth.User, error) {
	if u, ok := f.byEmail[email]; ok {
		return u, nil
	}
	return nil, auth.ErrNotFound
}

func (f *fakeStore) CountActiveAdmins(_ context.Context) (int64, error) {
	var n int64
	for _, u := range f.users {
		if u.Role == auth.RoleAdmin && u.IsActive {
			n++
		}
	}
	return n, nil
}

func (f *fakeStore) CreateUser(_ context.Context, u *auth.User, passwordHash string) (*auth.User, error) {
	cp := *u
	cp.ID = f.nextUserID
	f.nextUserID++
	f.users[cp.ID] = &cp
	f.byEmail[cp.Email] = &cp
	f.lastHash = passwordHash
	return &cp, nil
}

func (f *fakeStore) UpdateUser(_ context.Context, u *auth.User, passwordHash *string) (*auth.User, error) {
	existing, ok := f.users[u.ID]
	if !ok {
		return nil, auth.ErrNotFound
	}
	existing.Name = u.Name
	existing.Email = u.Email
	existing.Role = u.Role
	existing.Permissions = u.Permissions
	existing.IsActive = u.IsActive
	_ = passwordHash
	f.byEmail[u.Email] = existing
	return existing, nil
}

func (f *fakeStore) SetUserActive(_ context.Context, id int64, active bool) error {
	u, ok := f.users[id]
	if !ok {
		return auth.ErrNotFound
	}
	u.IsActive = active
	f.activated[id] = active
	return nil
}

func (f *fakeStore) DeleteUser(_ context.Context, id int64) error {
	if _, ok := f.users[id]; !ok {
		return auth.ErrNotFound
	}
	delete(f.users, id)
	f.deleted[id] = true
	return nil
}

func (f *fakeStore) ListRoles(_ context.Context) ([]db.Role, error) {
	out := []db.Role{}
	for _, r := range f.roles {
		out = append(out, r)
	}
	return out, nil
}

func (f *fakeStore) RoleByID(_ context.Context, id int64) (db.Role, error) {
	if r, ok := f.roles[id]; ok {
		return r, nil
	}
	return db.Role{}, auth.ErrNotFound
}

func (f *fakeStore) CreateRole(_ context.Context, name string) (db.Role, error) {
	r := db.Role{ID: f.nextRoleID, Name: name}
	f.nextRoleID++
	f.roles[r.ID] = r
	return r, nil
}

func (f *fakeStore) UpdateRole(_ context.Context, id int64, name string) (db.Role, error) {
	r, ok := f.roles[id]
	if !ok {
		return db.Role{}, auth.ErrNotFound
	}
	r.Name = name
	f.roles[id] = r
	return r, nil
}

func (f *fakeStore) DeleteRole(_ context.Context, id int64) error {
	if _, ok := f.roles[id]; !ok {
		return auth.ErrNotFound
	}
	delete(f.roles, id)
	return nil
}

func (f *fakeStore) RoleByName(_ context.Context, name string) (db.Role, error) {
	for _, r := range f.roles {
		if r.Name == name {
			return r, nil
		}
	}
	return db.Role{}, auth.ErrNotFound
}

func (f *fakeStore) CountUsersByRole(_ context.Context, role string) (int64, error) {
	var n int64
	for _, u := range f.users {
		if string(u.Role) == role {
			n++
		}
	}
	return n, nil
}

func (f *fakeStore) RenameRole(ctx context.Context, id int64, oldName, newName string) (db.Role, error) {
	r, ok := f.roles[id]
	if !ok {
		return db.Role{}, auth.ErrNotFound
	}
	r.Name = newName
	f.roles[id] = r
	for _, u := range f.users {
		if string(u.Role) == oldName {
			u.Role = auth.Role(newName)
		}
	}
	return r, nil
}

func (f *fakeStore) DeleteRoleTx(ctx context.Context, id int64, name string) error {
	if _, ok := f.roles[id]; !ok {
		return auth.ErrNotFound
	}
	n, _ := f.CountUsersByRole(ctx, name)
	if n > 0 {
		return fmt.Errorf("role %q is still assigned to %d user(s): %w", name, n, ErrInUse)
	}
	delete(f.roles, id)
	return nil
}

func (f *fakeStore) ListLogs(context.Context, LogQuery, int, int) ([]db.ListActivityLogsRow, int64, error) {
	return nil, 0, nil
}

func (f *fakeStore) InsertLog(_ context.Context, p db.CreateActivityLogParams) error {
	f.logs = append(f.logs, db.ActivityLog{Module: p.Module})
	return nil
}

func (f *fakeStore) ObjectLabels(_ context.Context, _ string, _ []int64) (map[int64]string, error) {
	return map[int64]string{}, nil
}

func (f *fakeStore) ClearLogs(context.Context) error {
	f.logs = nil
	return nil
}
func (f *fakeStore) ListStores(context.Context) ([]db.Store, error) { return f.stores, nil }

func (f *fakeStore) CreateStoreWithWarehouse(_ context.Context, sp db.CreateStoreParams, wh *db.CreateWarehouseParams) (db.Store, error) {
	whID := sp.WarehouseID
	if wh != nil {
		whID = 77 // provisioned paired warehouse
	}
	return db.Store{ID: 1, Code: sp.Code, WarehouseID: whID}, nil
}

func (f *fakeStore) UpdateStore(context.Context, db.UpdateStoreParams) (db.Store, error) {
	return db.Store{}, nil
}
func (f *fakeStore) StoreByID(_ context.Context, id int64) (db.Store, error) {
	if id == 99 {
		return db.Store{}, auth.ErrNotFound
	}
	if s, ok := f.storeRows[id]; ok {
		return s, nil
	}
	return db.Store{ID: id}, nil
}

func (f *fakeStore) DeleteStore(context.Context, int64) error { return nil }

func (f *fakeStore) CountStoresByWarehouse(_ context.Context, id int64) (int64, error) {
	return f.storesByWh[id], nil
}

func (f *fakeStore) WarehouseHasInventory(_ context.Context, id int64) (bool, error) {
	return f.whUsed[id], nil
}

func (f *fakeStore) DeactivateWarehouse(_ context.Context, id int64) error {
	f.deactivated = append(f.deactivated, id)
	return nil
}

func (f *fakeStore) DeleteWarehouse(_ context.Context, id int64) error {
	f.deletedWh = append(f.deletedWh, id)
	return nil
}

func (f *fakeStore) ListStoreUsers(context.Context, int64) ([]db.ListStoreUsersRow, error) {
	return nil, nil
}

func (f *fakeStore) AddStoreUser(_ context.Context, _, userID int64) error {
	return auth.ErrNotFound
}

func (f *fakeStore) RemoveStoreUser(context.Context, int64, int64) (int64, error) { return 0, nil }

func (f *fakeStore) RolePermissionMatrix(context.Context, db.DBTX) ([]db.ListRolePermissionsRow, error) {
	return nil, nil
}

func (f *fakeStore) UpsertRolePermission(context.Context, db.DBTX, string, string) error { return nil }

func (f *fakeStore) DeleteRolePermission(context.Context, db.DBTX, string, string) (int64, error) {
	return 0, nil
}

// fakeTx is the system package's transaction double (same shape as
// internal/sales and internal/finance). No statement on this package's
// transaction path issues raw SQL, so Exec/Query/QueryRow are inert; the
// interesting state is the boundary the service picked, plus the matrix
// snapshot a rollback restores.
type fakeTx struct {
	*fakeStore
	matrix     *fakeMatrix // optional: matrix rows to snapshot/restore
	snapshot   []db.ListRolePermissionsRow
	committed  bool
	rolledBack bool
}

func (f *fakeTx) Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) {
	return pgconn.CommandTag{}, nil
}
func (f *fakeTx) Query(context.Context, string, ...interface{}) (pgx.Rows, error) { return nil, nil }
func (f *fakeTx) QueryRow(context.Context, string, ...interface{}) pgx.Row        { return nil }
func (f *fakeTx) Commit(context.Context) error                                    { f.committed = true; return nil }
func (f *fakeTx) Rollback(context.Context) error {
	// pgx makes Rollback a no-op once Commit succeeded; a fake that restored
	// the snapshot unconditionally would undo committed writes.
	if f.committed {
		return nil
	}
	f.rolledBack = true
	if f.matrix != nil {
		f.matrix.perms = append([]db.ListRolePermissionsRow(nil), f.snapshot...)
	}
	return nil
}

func (f *fakeStore) Begin(context.Context) (Tx, error) {
	return &fakeTx{fakeStore: f}, nil
}

func (f *fakeMatrix) UpdateStore(context.Context, db.UpdateStoreParams) (db.Store, error) {
	return db.Store{}, nil
}

func newTestService(st Store) *Service {
	return NewService(st, time.UTC)
}

func TestCreateUserValidates(t *testing.T) {
	ctx := actorCtx(auth.RoleAdmin)
	s := newTestService(newFakeStore())

	cases := []struct {
		name    string
		in      UserInput
		wantErr bool
	}{
		{"short password", UserInput{Name: "A", Email: "a@b.co", Password: "123", Role: "sales"}, true},
		{"invalid role", UserInput{Name: "A", Email: "a@b.co", Password: "Str0ng!P@ss1", Role: "ceo"}, true},
		{"bad email", UserInput{Name: "A", Email: "nope", Password: "Str0ng!P@ss1", Role: "sales"}, true},
		{"missing name", UserInput{Email: "a@b.co", Password: "Str0ng!P@ss1", Role: "sales"}, true},
		{"valid", UserInput{Name: "A", Email: "a@b.co", Password: "Str0ng!P@ss1", Role: "sales"}, false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			_, err := s.CreateUser(ctx, tc.in)
			if tc.wantErr != (err != nil) {
				t.Fatalf("wantErr=%v, got %v", tc.wantErr, err)
			}
		})
	}
}

func TestCreateUserRejectsDuplicateEmail(t *testing.T) {
	s := newTestService(newFakeStore())
	_, err := s.CreateUser(actorCtx(auth.RoleAdmin), UserInput{
		Name: "B", Email: "admin@example.com", Password: "Str0ng!P@ss1", Role: "sales",
	})
	if !errors.Is(err, ErrEmailTaken) {
		t.Fatalf("expected ErrEmailTaken, got %v", err)
	}
}

func TestCreateUserHashesPassword(t *testing.T) {
	f := newFakeStore()
	s := newTestService(f)
	_, err := s.CreateUser(actorCtx(auth.RoleAdmin), UserInput{
		Name: "Sales", Email: "sales@example.com", Password: "secret123", Role: "sales",
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	if bcrypt.CompareHashAndPassword([]byte(f.lastHash), []byte("secret123")) != nil {
		t.Fatalf("password was not hashed with bcrypt: %q", f.lastHash)
	}
}

func TestUpdateUserChangesRoleAndActive(t *testing.T) {
	f := newFakeStore()
	s := newTestService(f)
	u, err := s.CreateUser(actorCtx(auth.RoleAdmin), UserInput{
		Name: "Staff", Email: "staff@example.com", Password: "secret123", Role: "hr_staff",
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	active := false
	upd, err := s.UpdateUser(actorCtx(auth.RoleAdmin), u.ID, 1, UserInput{
		Role:     "hr_manager",
		IsActive: &active,
	})
	if err != nil {
		t.Fatalf("update: %v", err)
	}
	if upd.Role != auth.RoleHRManager {
		t.Fatalf("role not updated: %s", upd.Role)
	}
	if upd.IsActive {
		t.Fatal("expected account deactivated")
	}
}

func TestUpdateUserRejectsDuplicateEmail(t *testing.T) {
	s := newTestService(newFakeStore())
	_, err := s.CreateUser(actorCtx(auth.RoleAdmin), UserInput{
		Name: "Other", Email: "other@example.com", Password: "secret123", Role: "sales",
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	_, err = s.UpdateUser(actorCtx(auth.RoleAdmin), 1, 2, UserInput{Email: "other@example.com"})
	if !errors.Is(err, ErrEmailTaken) {
		t.Fatalf("expected ErrEmailTaken, got %v", err)
	}
}

func TestUpdateUserPasswordChangeRevokesSessions(t *testing.T) {
	// R2-6b: an admin-initiated password change must end the target's
	// sessions, mirroring self-service behavior.
	s := newTestService(newFakeStore())
	var revoked []int64
	s.WithTokenRevoker(func(_ context.Context, userID int64) error {
		revoked = append(revoked, userID)
		return nil
	})
	u, err := s.CreateUser(actorCtx(auth.RoleAdmin), UserInput{
		Name: "Staff", Email: "pw@example.com", Password: "secret123", Role: "sales",
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	if _, err := s.UpdateUser(actorCtx(auth.RoleAdmin), u.ID, 1, UserInput{Password: "n3wStr0ng!"}); err != nil {
		t.Fatalf("update: %v", err)
	}
	if len(revoked) != 1 || revoked[0] != u.ID {
		t.Fatalf("revoked = %v, want [%d]", revoked, u.ID)
	}
}

func TestUpdateUserWithoutPasswordChangeRevokesNothing(t *testing.T) {
	s := newTestService(newFakeStore())
	revoked := 0
	s.WithTokenRevoker(func(_ context.Context, _ int64) error {
		revoked++
		return nil
	})
	u, err := s.CreateUser(actorCtx(auth.RoleAdmin), UserInput{
		Name: "Staff", Email: "nopw@example.com", Password: "secret123", Role: "sales",
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	if _, err := s.UpdateUser(actorCtx(auth.RoleAdmin), u.ID, 1, UserInput{Name: "Staff II"}); err != nil {
		t.Fatalf("update: %v", err)
	}
	if revoked != 0 {
		t.Fatalf("revoked = %d, want 0 (no credential change)", revoked)
	}
}

func TestDeleteUserBlocksSelf(t *testing.T) {
	f := newFakeStore()
	s := newTestService(f)
	err := s.DeleteUser(actorCtx(auth.RoleAdmin), 1, 1)
	if !errors.Is(err, ErrSelfDelete) {
		t.Fatalf("expected ErrSelfDelete, got %v", err)
	}
	// Add a second active admin so the deletion no longer hits the last-admin guard.
	if _, err := s.CreateUser(actorCtx(auth.RoleAdmin), UserInput{
		Name: "Admin2", Email: "admin2@example.com", Password: "secret123", Role: "admin",
	}); err != nil {
		t.Fatalf("create admin2: %v", err)
	}
	if err := s.DeleteUser(actorCtx(auth.RoleAdmin), 1, 2); err != nil {
		t.Fatalf("admin delete by another actor should work: %v", err)
	}
}

func TestToggleUserActive(t *testing.T) {
	f := newFakeStore()
	s := newTestService(f)
	u, err := s.CreateUser(actorCtx(auth.RoleAdmin), UserInput{
		Name: "Staff", Email: "staff@example.com", Password: "secret123", Role: "sales",
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	// Actor (admin, id 1) toggles the staff user (id u.ID) inactive.
	toggled, err := s.ToggleUserActive(actorCtx(auth.RoleAdmin), u.ID, 1)
	if err != nil {
		t.Fatalf("toggle: %v", err)
	}
	if toggled.IsActive {
		t.Fatal("expected user toggled to inactive")
	}
	u2, err := s.ToggleUserActive(actorCtx(auth.RoleAdmin), u.ID, 1)
	if err != nil {
		t.Fatalf("toggle back: %v", err)
	}
	if !u2.IsActive {
		t.Fatal("expected user toggled back to active")
	}
}

func TestToggleUserActiveBlocksSelf(t *testing.T) {
	s := newTestService(newFakeStore())
	if _, err := s.ToggleUserActive(actorCtx(auth.RoleAdmin), 1, 1); !errors.Is(err, ErrSelfEscalation) {
		t.Fatalf("expected ErrSelfEscalation, got %v", err)
	}
}

func TestLastAdminCannotBeDemoted(t *testing.T) {
	f := newFakeStore()
	s := newTestService(f)
	active := false
	// Only one active admin exists; demoting or deactivating them must fail.
	if _, err := s.UpdateUser(actorCtx(auth.RoleAdmin), 1, 2, UserInput{Role: "sales"}); !errors.Is(err, ErrLastAdmin) {
		t.Fatalf("expected ErrLastAdmin on demote, got %v", err)
	}
	if _, err := s.UpdateUser(actorCtx(auth.RoleAdmin), 1, 2, UserInput{IsActive: &active}); !errors.Is(err, ErrLastAdmin) {
		t.Fatalf("expected ErrLastAdmin on deactivate, got %v", err)
	}
	if err := s.DeleteUser(actorCtx(auth.RoleAdmin), 1, 2); !errors.Is(err, ErrLastAdmin) {
		t.Fatalf("expected ErrLastAdmin on delete, got %v", err)
	}
}

func TestAdminSelfEscalationBlocked(t *testing.T) {
	s := newTestService(newFakeStore())
	if _, err := s.UpdateUser(actorCtx(auth.RoleAdmin), 1, 1, UserInput{Role: "super_admin"}); !errors.Is(err, ErrSelfEscalation) {
		t.Fatalf("expected ErrSelfEscalation, got %v", err)
	}
}
func TestRoleLifecycle(t *testing.T) {
	ctx := actorCtx(auth.RoleAdmin)
	s := newTestService(newFakeStore())
	r, err := s.CreateRole(ctx, RoleInput{Name: "kasir_pusat"})
	if err != nil {
		t.Fatalf("create role: %v", err)
	}
	if r.Name != "kasir_pusat" {
		t.Fatalf("unexpected name %s", r.Name)
	}
	roles, err := s.ListRoles(ctx)
	if err != nil {
		t.Fatalf("list roles: %v", err)
	}
	seen := map[string]bool{}
	for _, v := range roles {
		seen[v.Name] = v.Builtin
	}
	if !seen["admin"] {
		t.Fatalf("admin row must be flagged builtin: %+v", roles)
	}
	if b, ok := seen["kasir_pusat"]; !ok || b {
		t.Fatalf("custom row must list with builtin=false: %+v", roles)
	}
	upd, err := s.UpdateRole(ctx, r.ID, RoleInput{Name: "kasir_cabang"})
	if err != nil {
		t.Fatalf("update role: %v", err)
	}
	if upd.Name != "kasir_cabang" {
		t.Fatalf("unexpected updated name %s", upd.Name)
	}
	if err := s.DeleteRole(ctx, r.ID); err != nil {
		t.Fatalf("delete role: %v", err)
	}
}

func TestCreateRoleRejectsBuiltinBadAndDuplicateNames(t *testing.T) {
	ctx := actorCtx(auth.RoleAdmin)
	s := newTestService(newFakeStore())
	for name, want := range map[string]error{
		"admin":                               ErrBuiltinRole,
		"owner":                               ErrBuiltinRole,
		"Bad-Name":                            ErrInvalidRoleName,
		"1leading":                            ErrInvalidRoleName,
		"has space":                           ErrInvalidRoleName,
		"waytoolongrolenameover32characters!": ErrInvalidRoleName,
	} {
		if _, err := s.CreateRole(ctx, RoleInput{Name: name}); !errors.Is(err, want) {
			t.Fatalf("create %q: expected %v, got %v", name, want, err)
		}
	}
	if _, err := s.CreateRole(ctx, RoleInput{Name: ""}); !errors.Is(err, ErrRoleNameRequired) {
		t.Fatalf("empty name: expected ErrRoleNameRequired, got %v", err)
	}
	if _, err := s.CreateRole(ctx, RoleInput{Name: "kasir_pusat"}); err != nil {
		t.Fatalf("seed custom role: %v", err)
	}
	if _, err := s.CreateRole(ctx, RoleInput{Name: "kasir_pusat"}); !errors.Is(err, ErrRoleExists) {
		t.Fatalf("duplicate: expected ErrRoleExists, got %v", err)
	}
}

func TestBuiltinRolesCannotBeRenamedOrDeleted(t *testing.T) {
	ctx := actorCtx(auth.RoleAdmin)
	s := newTestService(newFakeStore())
	if _, err := s.UpdateRole(ctx, 1, RoleInput{Name: "kasir_pusat"}); !errors.Is(err, ErrBuiltinRole) {
		t.Fatalf("rename builtin: expected ErrBuiltinRole, got %v", err)
	}
	if err := s.DeleteRole(ctx, 1); !errors.Is(err, ErrBuiltinRole) {
		t.Fatalf("delete builtin: expected ErrBuiltinRole, got %v", err)
	}
}

func TestDeleteRoleInUseReturnsConflict(t *testing.T) {
	ctx := actorCtx(auth.RoleAdmin)
	st := newFakeStore()
	s := newTestService(st)
	r, err := s.CreateRole(ctx, RoleInput{Name: "kasir_pusat"})
	if err != nil {
		t.Fatalf("create role: %v", err)
	}
	st.users[9] = &auth.User{ID: 9, Name: "Kasir", Email: "kasir@x.co", Role: "kasir_pusat", IsActive: true}
	if err := s.DeleteRole(ctx, r.ID); !errors.Is(err, ErrInUse) {
		t.Fatalf("delete in-use: expected ErrInUse, got %v", err)
	}
}

func TestRegistryRoleIsValidForUsers(t *testing.T) {
	ctx := actorCtx(auth.RoleAdmin)
	st := newFakeStore()
	s := newTestService(st)
	if _, err := s.CreateRole(ctx, RoleInput{Name: "kasir_pusat"}); err != nil {
		t.Fatalf("create role: %v", err)
	}
	u, err := s.CreateUser(ctx, UserInput{Name: "K", Email: "k@x.co", Password: "Str0ng!P@ss1", Role: "kasir_pusat"})
	if err != nil {
		t.Fatalf("create user with registry role: %v", err)
	}
	if string(u.Role) != "kasir_pusat" {
		t.Fatalf("unexpected role %s", u.Role)
	}
}

func TestInsertLogRecords(t *testing.T) {
	f := newFakeStore()
	mod, act := "categories", "post"
	desc := "POST /api/v1/categories"
	ip := "127.0.0.1"
	ua := "test"
	params := db.CreateActivityLogParams{
		UserID:      int8(1),
		Module:      str(&mod),
		Action:      str(&act),
		Description: str(&desc),
		IpAddress:   str(&ip),
		UserAgent:   str(&ua),
	}
	if err := f.InsertLog(context.Background(), params); err != nil {
		t.Fatalf("insert: %v", err)
	}
	if len(f.logs) != 1 || !f.logs[0].Module.Valid || f.logs[0].Module.String != "categories" {
		t.Fatalf("log not recorded: %+v", f.logs)
	}
	if err := f.ClearLogs(context.Background()); err != nil {
		t.Fatalf("clear: %v", err)
	}
	if len(f.logs) != 0 {
		t.Fatal("logs not cleared")
	}
}

func TestClassify(t *testing.T) {
	cases := []struct {
		path, method string
		module, act  string
		modelID      int64
	}{
		{"/api/v1/customers", "POST", "customers", "post", 0},
		{"/api/v1/sales-orders/12/confirm", "POST", "sales-orders", "post/confirm", 12},
		{"/api/v1/inventory/stock-in", "POST", "inventory/stock-in", "post", 0},
		{"/api/v1/users/5/toggle", "POST", "users", "post/toggle", 5},
		{"/api/v1/purchase-orders/1/pay", "POST", "purchase-orders", "post/pay", 1},
	}
	for _, tc := range cases {
		module, action, modelID := classify(tc.path, tc.method)
		if module != tc.module || action != tc.act {
			t.Fatalf("%s: got (%s, %s), want (%s, %s)", tc.path, module, action, tc.module, tc.act)
		}
		if tc.modelID != 0 && (modelID == nil || *modelID != tc.modelID) {
			t.Fatalf("%s: got modelID %v, want %d", tc.path, modelID, tc.modelID)
		}
	}
}

func TestStoreInputValidation(t *testing.T) {
	ctx := context.Background()
	s := newTestService(newFakeStore())

	if _, err := s.CreateStore(ctx, StoreInput{Code: "  ", Name: "A"}); err == nil {
		t.Fatal("empty code accepted")
	}
	if _, err := s.CreateStore(ctx, StoreInput{Code: "STR", Name: ""}); err == nil {
		t.Fatal("empty name accepted")
	}
	if _, err := s.UpdateStore(ctx, 99, StoreInput{Code: "STR", Name: "A"}); !errors.Is(err, auth.ErrNotFound) {
		t.Fatalf("missing store update: got %v", err)
	}
	if err := s.DeleteStore(ctx, 99); !errors.Is(err, auth.ErrNotFound) {
		t.Fatalf("missing store delete: got %v", err)
	}
}

func TestCreateStoreProvisionsWarehouse(t *testing.T) {
	ctx := context.Background()
	s := newTestService(newFakeStore())

	// No warehouse_id → service provisions the paired warehouse (fake: 77).
	got, err := s.CreateStore(ctx, StoreInput{Code: "STR-1", Name: "Outlet Satu"})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	if got.WarehouseID != 77 {
		t.Fatalf("auto-provision: warehouse_id = %d, want 77", got.WarehouseID)
	}

	// Explicit warehouse_id → used as-is, no provisioning.
	wid := int64(9)
	got, err = s.CreateStore(ctx, StoreInput{Code: "STR-2", Name: "Outlet Dua", WarehouseID: &wid})
	if err != nil {
		t.Fatalf("create with warehouse: %v", err)
	}
	if got.WarehouseID != 9 {
		t.Fatalf("explicit warehouse: warehouse_id = %d, want 9", got.WarehouseID)
	}
}

func TestAddRemoveStoreUser(t *testing.T) {
	ctx := context.Background()
	s := newTestService(newFakeStore())

	if err := s.AddStoreUser(ctx, 1, 999); !errors.Is(err, auth.ErrNotFound) {
		t.Fatalf("unknown user add: got %v", err)
	}
}

// scopedCtx simulates auth.Middleware + ScopeMiddleware for a caller bound to
// the given outlet(s) — the kepala toko case (StoreScope hardening,
// authz audit 2026-09-09).
func scopedCtx(storeIDs ...int64) context.Context {
	return auth.WithStoreScope(context.Background(), auth.NewStoreScope(storeIDs))
}

func TestListStoresScopesToCallerOutlets(t *testing.T) {
	f := newFakeStore()
	f.stores = []db.Store{{ID: 1, Code: "A"}, {ID: 2, Code: "B"}, {ID: 3, Code: "C"}}
	s := newTestService(f)

	got, err := s.ListStores(scopedCtx(2))
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 1 || got[0].ID != 2 {
		t.Fatalf("scoped list = %+v, want only store 2", got)
	}

	all, err := s.ListStores(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if len(all) != 3 {
		t.Fatalf("platform-wide list = %d stores, want 3", len(all))
	}
}

func TestStoreAccessOutsideScopeIsForbidden(t *testing.T) {
	s := newTestService(newFakeStore())
	ctx := scopedCtx(2)

	if _, err := s.GetStore(ctx, 1); !errors.Is(err, ErrStoreOutOfScope) {
		t.Fatalf("out-of-scope get: got %v, want ErrStoreOutOfScope", err)
	}
	if _, err := s.GetStore(ctx, 2); err != nil {
		t.Fatalf("in-scope get: %v", err)
	}

	in := StoreInput{Code: "B", Name: "Outlet B"}
	if _, err := s.UpdateStore(ctx, 1, in); !errors.Is(err, ErrStoreOutOfScope) {
		t.Fatalf("out-of-scope update: got %v, want ErrStoreOutOfScope", err)
	}
	if _, err := s.UpdateStore(ctx, 2, in); err != nil {
		t.Fatalf("in-scope update: %v", err)
	}

	if _, err := s.ListStoreUsers(ctx, 1); !errors.Is(err, ErrStoreOutOfScope) {
		t.Fatalf("out-of-scope user list: got %v, want ErrStoreOutOfScope", err)
	}
	if err := s.AddStoreUser(ctx, 1, 1); !errors.Is(err, ErrStoreOutOfScope) {
		t.Fatalf("out-of-scope user add: got %v, want ErrStoreOutOfScope", err)
	}
	if err := s.RemoveStoreUser(ctx, 1, 1); !errors.Is(err, ErrStoreOutOfScope) {
		t.Fatalf("out-of-scope user remove: got %v, want ErrStoreOutOfScope", err)
	}
}

// End-to-end over the HTTP layer: the out-of-scope rejection must surface as
// a 403 httpresponse error, not a generic 400.
func TestUpdateStoreOutOfScopeReturnsHTTP403(t *testing.T) {
	// Mirror production boot (cmd/server/main.go loadMatrix): the route gate
	// checks the DB-loaded matrix, so install the grant under test and reset
	// afterwards — otherwise RequirePermission 403s with a generic body
	// before the scope check under test ever runs.
	auth.SetRoleMatrix(map[auth.Role][]string{
		auth.RoleStoreManager: {"system.stores.update"},
	})
	defer auth.SetRoleMatrix(nil)
	h := NewHandler(newTestService(newFakeStore()))
	r := chi.NewRouter()
	r.Use(func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
			ctx := auth.ToContext(req.Context(), &auth.User{ID: 5, Role: auth.RoleStoreManager}, 5)
			ctx = auth.WithStoreScope(ctx, auth.NewStoreScope([]int64{2}))
			next.ServeHTTP(w, req.WithContext(ctx))
		})
	})
	h.RegisterRoutes(r)

	req := httptest.NewRequest(http.MethodPut, "/stores/1", strings.NewReader(`{"code":"A","name":"Outlet A"}`))
	rec := httptest.NewRecorder()
	r.ServeHTTP(rec, req)

	if rec.Code != http.StatusForbidden {
		t.Fatalf("status = %d, want 403 (body %s)", rec.Code, rec.Body.String())
	}
	if !strings.Contains(rec.Body.String(), "store is outside your outlet scope") {
		t.Fatalf("body = %s", rec.Body.String())
	}
}

func TestRolePermissionMatrix(t *testing.T) {
	ctx := context.Background()
	fm := &fakeMatrix{fakeStore: newFakeStore(), perms: []db.ListRolePermissionsRow{{Role: "sales", Permission: "inventory.stock.view"}}}
	s := newTestService(fm)

	view, err := s.RolePermissionMatrix(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if len(view.Rows) != 1 || view.Rows[0].Role != "sales" || view.Rows[0].Permission != "inventory.stock.view" {
		t.Fatalf("bad matrix rows: %+v", view.Rows)
	}
	// Row set wins: sales resolves exactly its rows ...
	if got := view.Effective["sales"]; !got["inventory.stock.view"] || got["*"] {
		t.Fatalf("sales effective must hold exactly its row set: %+v", got)
	}
	// ... a role with zero rows is deny-all (DB-only, no builtin fallback) ...
	if got := view.Effective["cashier"]; len(got) != 0 {
		t.Fatalf("cashier effective must be empty without rows: %+v", got)
	}
	// ... while admin/owner keep the wildcard parity publish.
	if got := view.Effective["admin"]; !got["*"] || len(got) != 1 {
		t.Fatalf("admin effective must hold only the wildcard: %+v", got)
	}
	if err := s.SetRolePermission(ctx, "sales", "inventory.stock.view", false); err != nil {
		t.Fatalf("revoke: %v", err)
	}
	if len(fm.perms) != 0 {
		t.Fatalf("revoke should delete row, got %+v", fm.perms)
	}
	if err := s.SetRolePermission(ctx, "sales", "sales.sales-orders.view", true); err != nil {
		t.Fatalf("grant: %v", err)
	}
	if len(fm.perms) != 1 {
		t.Fatalf("grant should upsert row, got %+v", fm.perms)
	}
	// Unknown keys are rejected on grant and revoke alike.
	if err := s.SetRolePermission(ctx, "sales", "inventory.stock.read", true); !errors.Is(err, ErrUnknownPermission) {
		t.Fatalf("grant of unknown key must fail with ErrUnknownPermission, got %v", err)
	}
	if err := s.SetRolePermission(ctx, "sales", "inventory.stock.read", false); !errors.Is(err, ErrUnknownPermission) {
		t.Fatalf("revoke of unknown key must fail with ErrUnknownPermission, got %v", err)
	}
}

func TestUserPermissionsRejectUnknownKeys(t *testing.T) {
	s := newTestService(newFakeStore())
	base := UserInput{Name: "A", Email: "a@b.co", Password: "Str0ng!P@ss1", Role: "cashier"}
	bad := base
	bad.Permissions = map[string]bool{"cashier.typo": true}
	if _, err := s.CreateUser(actorCtx(auth.RoleAdmin), bad); !errors.Is(err, ErrUnknownPermission) {
		t.Fatalf("create with unknown override must fail with ErrUnknownPermission, got %v", err)
	}
	dead := base
	dead.Email = "b@b.co"
	dead.Permissions = map[string]bool{"nope.*": true}
	if _, err := s.CreateUser(actorCtx(auth.RoleAdmin), dead); !errors.Is(err, ErrUnknownPermission) {
		t.Fatalf("create with dead glob override must fail with ErrUnknownPermission, got %v", err)
	}
	// A catalog-covering glob stays storable (wildcard-deny mechanics).
	deny := base
	deny.Email = "c@b.co"
	deny.Permissions = map[string]bool{"sales.*": false}
	u, err := s.CreateUser(actorCtx(auth.RoleAdmin), deny)
	if err != nil {
		t.Fatalf("deny-only covering glob must be allowed, got %v", err)
	}
	// The update path validates too.
	upd := UserInput{Permissions: map[string]bool{"cashier.typo": false}}
	if _, err := s.UpdateUser(actorCtx(auth.RoleAdmin), u.ID, 1, upd); !errors.Is(err, ErrUnknownPermission) {
		t.Fatalf("update with unknown override must fail with ErrUnknownPermission, got %v", err)
	}
}

func TestRenameRoleCascadesUsersAndMatrix(t *testing.T) {
	ctx := actorCtx(auth.RoleAdmin)
	st := newFakeStore()
	fm := &fakeMatrix{fakeStore: st, perms: []db.ListRolePermissionsRow{
		{Role: "kasir_pusat", Permission: "cashier.*"},
		{Role: "kasir_pusat", Permission: "sales.pos.create"},
	}}
	s := newTestService(fm)
	r, err := s.CreateRole(ctx, RoleInput{Name: "kasir_pusat"})
	if err != nil {
		t.Fatalf("create role: %v", err)
	}
	st.users[9] = &auth.User{ID: 9, Name: "Kasir", Email: "kasir@x.co", Role: "kasir_pusat", IsActive: true}
	upd, err := s.UpdateRole(ctx, r.ID, RoleInput{Name: "kasir_cabang"})
	if err != nil {
		t.Fatalf("rename: %v", err)
	}
	if upd.Name != "kasir_cabang" {
		t.Fatalf("unexpected name %s", upd.Name)
	}
	if string(st.users[9].Role) != "kasir_cabang" {
		t.Fatalf("user role not cascaded: %s", st.users[9].Role)
	}
	for _, p := range fm.perms {
		if p.Role == "kasir_pusat" {
			t.Fatalf("matrix row not cascaded: %+v", fm.perms)
		}
	}
	if len(fm.perms) != 2 {
		t.Fatalf("matrix rows lost: %+v", fm.perms)
	}
	delete(st.users, 9)
	if err := s.DeleteRole(ctx, r.ID); err != nil {
		t.Fatalf("delete renamed role: %v", err)
	}
}

type fakeMatrix struct {
	*fakeStore
	perms []db.ListRolePermissionsRow
	// lastTx is the transaction the service opened for the most recent
	// role-permission write, so a test can assert the boundary it chose.
	lastTx *fakeTx
	// failRead / failUpsert / deleteErrPerm / deleteWildcardZero inject the
	// write results the pre-2026-09-27 code discarded.
	failRead           bool
	failUpsert         bool
	deleteErrPerm      string
	deleteWildcardZero bool
	deleteCalls        []string
	upsertCalls        []string
}

func (f *fakeMatrix) Begin(context.Context) (Tx, error) {
	f.lastTx = &fakeTx{
		fakeStore: f.fakeStore,
		matrix:    f,
		snapshot:  append([]db.ListRolePermissionsRow(nil), f.perms...),
	}
	return f.lastTx, nil
}

func (f *fakeMatrix) RolePermissionMatrix(context.Context, db.DBTX) ([]db.ListRolePermissionsRow, error) {
	if f.failRead {
		return nil, errors.New("matrix read failed")
	}
	return f.perms, nil
}

func (f *fakeMatrix) UpsertRolePermission(_ context.Context, _ db.DBTX, role, perm string) error {
	f.upsertCalls = append(f.upsertCalls, perm)
	if f.failUpsert {
		return errors.New("upsert failed")
	}
	for _, p := range f.perms {
		if p.Role == role && p.Permission == perm {
			return nil
		}
	}
	f.perms = append(f.perms, db.ListRolePermissionsRow{Role: role, Permission: perm})
	return nil
}

func (f *fakeMatrix) DeleteRolePermission(_ context.Context, _ db.DBTX, role, perm string) (int64, error) {
	f.deleteCalls = append(f.deleteCalls, perm)
	if f.deleteErrPerm != "" && perm == f.deleteErrPerm {
		return 0, errors.New("delete failed")
	}
	if f.deleteWildcardZero && strings.HasSuffix(perm, ".*") {
		return 0, nil
	}
	out := f.perms[:0]
	var n int64
	for _, p := range f.perms {
		if p.Role == role && p.Permission == perm {
			n++
			continue
		}
		out = append(out, p)
	}
	f.perms = out
	return n, nil
}

func (f *fakeMatrix) RenameRole(ctx context.Context, id int64, oldName, newName string) (db.Role, error) {
	r, err := f.fakeStore.RenameRole(ctx, id, oldName, newName)
	if err != nil {
		return r, err
	}
	for i := range f.perms {
		if f.perms[i].Role == oldName {
			f.perms[i].Role = newName
		}
	}
	return r, nil
}

func (f *fakeMatrix) DeleteRoleTx(ctx context.Context, id int64, name string) error {
	if err := f.fakeStore.DeleteRoleTx(ctx, id, name); err != nil {
		return err
	}
	kept := f.perms[:0]
	for _, p := range f.perms {
		if p.Role != name {
			kept = append(kept, p)
		}
	}
	for i := len(kept); i < len(f.perms); i++ {
		f.perms[i] = db.ListRolePermissionsRow{}
	}
	f.perms = kept
	return nil
}

// actorCtx carries an authenticated actor of the given role. Every /users and
// /roles route runs behind auth.Middleware, so the service always sees one;
// guardAssignableRole (migration 000044) fails closed without it.
func actorCtx(role auth.Role) context.Context {
	return auth.ToContext(context.Background(), &auth.User{ID: 1, Role: role}, 1)
}

func TestCreateUserRoleAssignmentIsScopedToActor(t *testing.T) {
	cases := []struct {
		name    string
		actor   auth.Role
		assign  string
		wantErr bool
	}{
		{"store manager hires a cashier", auth.RoleStoreManager, "cashier", false},
		{"store manager hires an employee", auth.RoleStoreManager, "employee", false},
		{"store manager cannot mint an admin", auth.RoleStoreManager, "admin", true},
		{"store manager cannot mint finance", auth.RoleStoreManager, "finance", true},
		{"store manager cannot mint gm", auth.RoleStoreManager, "gm", true},
		{"store manager cannot clone itself", auth.RoleStoreManager, "store_manager", true},
		{"gm appoints a store manager", auth.RoleGM, "store_manager", false},
		{"gm cannot mint an owner", auth.RoleGM, "owner", true},
		{"gm cannot mint an admin", auth.RoleGM, "admin", true},
		{"gm cannot mint finance", auth.RoleGM, "finance", true},
		{"admin may assign anything", auth.RoleAdmin, "owner", false},
		{"cashier may not create users at all", auth.RoleCashier, "cashier", true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			s := newTestService(newFakeStore())
			_, err := s.CreateUser(actorCtx(tc.actor), UserInput{
				Name: "A", Email: "a@b.co", Password: "Str0ng!P@ss1", Role: tc.assign,
			})
			if tc.wantErr != errors.Is(err, ErrRoleNotAssignable) {
				t.Fatalf("assign %q as %q: wantErr=%v, got %v", tc.assign, tc.actor, tc.wantErr, err)
			}
		})
	}
}

// A store manager must not be able to seize a higher account by demoting it
// into its own allowlist and resetting the password.
func TestUpdateUserGuardsTheExistingRoleToo(t *testing.T) {
	s := newTestService(newFakeStore())
	victim, err := s.CreateUser(actorCtx(auth.RoleAdmin), UserInput{
		Name: "Boss", Email: "boss@b.co", Password: "Str0ng!P@ss1", Role: "gm",
	})
	if err != nil {
		t.Fatalf("seed victim: %v", err)
	}
	_, err = s.UpdateUser(actorCtx(auth.RoleStoreManager), victim.ID, 99, UserInput{Role: "cashier"})
	if !errors.Is(err, ErrRoleNotAssignable) {
		t.Fatalf("expected ErrRoleNotAssignable, got %v", err)
	}
	_, err = s.UpdateUser(actorCtx(auth.RoleStoreManager), victim.ID, 99, UserInput{Password: "An0ther!P@ss"})
	if !errors.Is(err, ErrRoleNotAssignable) {
		t.Fatalf("expected password reset to be blocked, got %v", err)
	}
}

func TestCreateUserBlocksPermissionsGrantEscalation(t *testing.T) {
	s := newTestService(newFakeStore())
	_, err := s.CreateUser(actorCtx(auth.RoleStoreManager), UserInput{
		Name: "Sneaky", Email: "sneaky@b.co", Password: "Str0ng!P@ss1",
		Role: "cashier", Permissions: map[string]bool{"accounting.journals.post": true},
	})
	if !errors.Is(err, ErrRoleNotAssignable) {
		t.Fatalf("grant-escalation via permissions must be blocked, got %v", err)
	}
	// Deny-only overrides stay allowed.
	if _, err := s.CreateUser(actorCtx(auth.RoleStoreManager), UserInput{
		Name: "Narrow", Email: "narrow@b.co", Password: "Str0ng!P@ss1",
		Role: "cashier", Permissions: map[string]bool{"cashier.shifts.view": false},
	}); err != nil {
		t.Fatalf("deny-only override must be allowed, got %v", err)
	}
}

func TestUpdateUserBlocksSelfGrantViaPermissions(t *testing.T) {
	s := newTestService(newFakeStore())
	self, err := s.CreateUser(actorCtx(auth.RoleAdmin), UserInput{
		Name: "SM", Email: "sm@b.co", Password: "Str0ng!P@ss1", Role: "store_manager",
	})
	if err != nil {
		t.Fatalf("seed self: %v", err)
	}
	smCtx := auth.ToContext(context.Background(), &auth.User{ID: self.ID, Role: auth.RoleStoreManager}, 7)
	_, err = s.UpdateUser(smCtx, self.ID, self.ID, UserInput{
		Permissions: map[string]bool{"system.roles.update": true},
	})
	if !errors.Is(err, ErrRoleNotAssignable) {
		t.Fatalf("self-grant via permissions must be blocked, got %v", err)
	}
}

func TestCashierDerivesPOSApplicationAccess(t *testing.T) {
	if got := auth.DeriveApplicationAccess(auth.RoleCashier, false); got != auth.AppPOS {
		t.Fatalf("cashier should reach the POS shell, got %q", got)
	}
	if got := auth.DeriveApplicationAccess(auth.RoleStoreManager, false); got != auth.AppERP {
		t.Fatalf("store manager belongs in ERP, got %q", got)
	}
}
