package finance

// Outlet scoping for the cashflow surface (migration 000104).
//
// WHY THIS FILE EXISTS RATHER THAN FIVE INLINE PREDICATES. The cashflow data is
// reachable through five routes, and four of the five AGGREGATE over the table:
// a sum over unscoped rows is the company's total movement even when no
// individual row is shown, and a CSV is easier to exfiltrate than a paginated
// list. So "filter the list" is not a sufficient version of this change, and the
// version that is sufficient has to be repeated identically in five places
// without any of them being forgotten later.
//
// One resolver, one meaning, five call sites. The five are enumerated in
// migration 000104 §4 and each is asserted by TestCashflowOutletScoping_
// AllReadPathsAreScoped, so a sixth read path added later has to be added there
// too or that test fails.
//
// NO ROLE CHECK ANYWHERE IN THIS FILE. Scoping is by the caller's store scope
// (auth.StoreScope), never by u.Role. A role check widens silently the moment a
// new role is added that should be restricted, and this whole piece of work
// exists because grants accreted without anyone asking who they reached.

import (
	"context"
	"errors"

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

// ErrNoStoreScope is returned when a request reaches a cashflow surface with no
// store scope attached at all. See cashflowStoreScope for why that is a
// refusal rather than a fallback to platform-wide.
var ErrNoStoreScope = errors.New("cashflow: no store scope on context")

// cashflowStoreScope resolves the caller's outlet scope for a cashflow read.
//
// THE FAIL-CLOSED DECISION, stated here because it is the whole point of the
// helper:
//
//   - Scope attached AND platform-wide (admin/owner, or a user with no
//     store_users row) -> nil ids, which the SQL renders as NULL, which matches
//     every row INCLUDING company-level (store_id IS NULL). Unchanged
//     behaviour for the roles that reconcile the company books.
//   - Scope attached AND restricted (a till operator, a store manager) -> that
//     role's store ids. The SQL predicate `store_id = ANY(ids)` cannot match a
//     NULL store_id, so company-level rows are excluded BY CONSTRUCTION rather
//     than by a second condition somebody has to remember.
//   - NO scope attached at all -> ErrNoStoreScope. Deny.
//
// The last case is the one that needs defending. auth.StoreScopeFrom returns an
// empty StoreScope{} when the middleware did not run, and an empty StoreScope
// answers PlatformWide() == true — because an admin's legitimate scope is also
// "no ids". Reading that as platform-wide means a request that never passed
// through ScopeMiddleware sees the whole company. For the surfaces that already
// behave that way that is a compatibility default; for a surface that must not
// leak, "the middleware never ran" and "this caller is an administrator" are
// different statements and only one of them should grant access.
//
// So the absence is checked EXPLICITLY, via auth.HasStoreScope, and answered by
// refusing. The cost is that an unmounted or hand-constructed call gets an error
// where it used to get data; that is the intended direction for a money surface.
//
// A restricted scope with an EMPTY id list is NOT this case: it is a real scope
// that resolves to no stores, and it produces a non-nil empty slice, which the
// SQL matches against nothing. Fail closed by a different route, same outcome.
func cashflowStoreScope(ctx context.Context) ([]int64, error) {
	if !auth.HasStoreScope(ctx) {
		return nil, ErrNoStoreScope
	}
	return auth.StoreScopeFrom(ctx).IDs(), nil
}

// cashflowWriteStore derives the outlet for a NEW cashflow row.
//
// The outlet is never read from the request body. A client that can name its own
// outlet defeats every filter in the read paths, because it would simply write
// its rows into a store it does not own and then read them back.
//
// The derivation is auth.StoreScopeFrom(ctx).ResolveStoreBinding(nil) — the
// same seam ProcessPOS uses for a sales order (internal/sales/service.go:1226) —
// which resolves to:
//
//   - a restricted single-store caller: that store, implied;
//   - a restricted multi-store caller: denied, because nil input cannot be
//     resolved and the caller must therefore be acting through a path that
//     supplies the binding;
//   - a platform-wide caller: nil, i.e. a company-level row, which is the
//     correct reading of "the company booked this" written by someone with no
//     outlet of their own.
//
// nil is passed rather than a client-supplied id on purpose: the input type
// exists for callers that genuinely have a choice (a sales order may name the
// outlet it was placed in), and using it here would reintroduce exactly the
// hole this lane closes.
func cashflowWriteStore(ctx context.Context) (*int64, error) {
	bound, ok := auth.StoreScopeFrom(ctx).ResolveStoreBinding(nil)
	if !ok {
		return nil, ErrForbidden
	}
	return bound, nil
}

// cashflowWriteStoreOrNil is cashflowWriteStore without the error, for callers
// that have ALREADY proved the caller may act (they hold a scoped lock on the
// row, so the scope check in cashflowStoreScope has already run and passed).
//
// It exists so the denial is not enforced twice in two different ways in one
// function: ReverseCashflow resolves the scope at the top and would otherwise
// re-resolve the binding mid-transaction, where a failure would leave a lock
// held for a write that cannot complete.
func cashflowWriteStoreOrNil(ctx context.Context) *int64 {
	bound, _ := auth.StoreScopeFrom(ctx).ResolveStoreBinding(nil)
	return bound
}
