package finance

// Outlet scoping for the cashflow surface (migration 000104).
//
// The brief listed FIVE read paths. Four of them are here and one is not, and
// finding out which is why is recorded: GET /cashflow-balances/accounts reads
// journal_lines joined to posted journal_entries and NO cashflow rows at all
// (see the AccountBalances comment in hardening.go). Adding a store predicate
// there would have been cargo-culting. The five paths are enumerated in
// migration 000104 §4, and TestCashflowOutletScoping_AllReadPathsAreScoped
// below is what stops a sixth read path from being added without a decision.

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

	"github.com/shopspring/decimal"
)

// cashflowWorld is a store holding one row per outlet plus a company-level row,
// which is the shape the backfill can actually produce: an outlet row, a row
// whose creator had no store binding, and a row belonging to a different outlet.
type cashflowWorld struct {
	store     *fakeStore
	ownOutlet int64
	own       int64
	other     int64
	companyLv int64
}

func newCashflowWorld(t *testing.T) *cashflowWorld {
	t.Helper()
	s := svc()
	st := s.store.(*fakeStore)
	id := int64(100)
	next := func() int64 { id++; return id }
	w := &cashflowWorld{store: st, ownOutlet: 1, own: next(), other: next(), companyLv: next()}
	st.cashflows[w.own] = &Cashflow{ID: w.own, TransactionNumber: "CF-OWN", Amount: decimal.NewFromInt(10), StoreID: &w.ownOutlet}
	st.cashflows[w.other] = &Cashflow{ID: w.other, TransactionNumber: "CF-OTHER", Amount: decimal.NewFromInt(99), StoreID: ptrInt64(2)}
	st.cashflows[w.companyLv] = &Cashflow{ID: w.companyLv, TransactionNumber: "CF-COMPANY", Amount: decimal.NewFromInt(777)}
	return w
}

func ptrInt64(v int64) *int64 { return &v }

// READ PATH 1 — GET /cashflows. A till operator restricted to outlet 1 sees
// exactly its own row: not the other outlet's, and not the company-level row.
// Asserted on the ROWS, because a scoping test that only checks the parameter
// would pass against a service that set the filter and then ignored it.
func TestCashflowOutletScoping_ListSeesOwnOutletOnly(t *testing.T) {
	w := newCashflowWorld(t)
	s := svc()
	s.store = w.store

	rows, total, _, err := s.ListCashflows(outletCtx(1), CashflowFilter{})
	if err != nil {
		t.Fatalf("ListCashflows err = %v, want nil (a scoped list is an empty list, not an error)", err)
	}
	if total != 1 || len(rows) != 1 {
		t.Fatalf("scoped list returned %d rows (total %d), want 1", len(rows), total)
	}
	if rows[0].ID != w.own {
		t.Errorf("scoped list returned cashflow %d (%s), want their own %d",
			rows[0].ID, rows[0].TransactionNumber, w.own)
	}
	if rows[0].StoreID == nil || *rows[0].StoreID != 1 {
		t.Errorf("returned store_id = %v, want 1", rows[0].StoreID)
	}

	// The other two rows must be invisible by name, so a failure says WHICH kind
	// of row leaked rather than just "a count was wrong".
	for _, r := range rows {
		switch r.ID {
		case w.other, w.companyLv:
			t.Errorf("leaked %s (%d) to a caller scoped to outlet 1", r.TransactionNumber, r.ID)
		}
	}
}

// The scope must actually reach the STORE, not merely exist on the filter. A
// service that built the filter and forgot to pass it is exactly the shape of
// bug this lane exists to prevent, so the value is read back off the store.
func TestCashflowOutletScoping_FilterReachesTheStore(t *testing.T) {
	for _, tc := range []struct {
		name string
		ctx  context.Context
		want []int64
	}{
		{"restricted", outletCtx(1, 3), []int64{1, 3}},
		// nil means platform-wide. It is nil and NOT an empty slice, and the
		// difference is the whole fail-closed story: pgx sends a nil slice as
		// SQL NULL, which matches every row, where an empty slice sends '{}'
		// and matches none.
		{"platform-wide", platformCtx(), nil},
		// A restricted scope resolving to zero stores. The empty case is the
		// dangerous one, because an empty slice is easy to write by accident
		// where a nil was meant — see outletCtx.
		{"restricted-to-nothing", outletCtx(), []int64{}},
	} {
		t.Run(tc.name, func(t *testing.T) {
			s := svc()
			if _, _, _, err := s.ListCashflows(tc.ctx, CashflowFilter{}); err != nil {
				t.Fatalf("err = %v", err)
			}
			st := s.store.(*fakeStore)
			// The SET flag, not a nil check: nil is the correct value for
			// platform-wide, so a nil check cannot tell a correct result from a
			// service that never reached the store.
			if !st.listCashflowsScopeSet {
				t.Fatal("the service never reached the store, so StoreIDs was never passed")
			}
			got := st.listCashflowsScope
			if len(got) != len(tc.want) {
				t.Fatalf("StoreIDs = %v, want %v", got, tc.want)
			}
			for i := range got {
				if got[i] != tc.want[i] {
					t.Fatalf("StoreIDs = %v, want %v", got, tc.want)
				}
			}
		})
	}
}

// READ PATH 2 — GET /cashflows/{id}. This is the one that decides whether the
// others mean anything: a scoped list with an UNSCOPED id fetch is a list with a
// hole in it, because ids are small sequential integers and a till operator can
// simply walk them.
func TestCashflowOutletScoping_GetByIDRefusesOtherOutletsAndCompanyRows(t *testing.T) {
	w := newCashflowWorld(t)
	s := svc()
	s.store = w.store
	ctx := outletCtx(1)

	got, err := s.GetCashflow(ctx, w.own)
	if err != nil {
		t.Fatalf("their own cashflow = %v, want it readable", err)
	}
	if got.ID != w.own {
		t.Errorf("read cashflow %d, want %d", got.ID, w.own)
	}

	for name, id := range map[string]int64{
		"another outlet": w.other,
		// A company-level row is invisible to a restricted caller, which is the
		// point of NULL meaning company-level rather than "unknown".
		"company level": w.companyLv,
	} {
		t.Run(name, func(t *testing.T) {
			if _, err := s.GetCashflow(ctx, id); !errors.Is(err, ErrNotFound) {
				t.Errorf("GetCashflow(%d) err = %v, want ErrNotFound (indistinguishable from a missing row)", id, err)
			}
		})
	}
}

// A platform-wide caller keeps the full surface, company-level rows included.
// This is the half of the change that must not regress: the roles that reconcile
// the company books would otherwise lose the ability to.
func TestCashflowOutletScoping_PlatformWideSeesEverything(t *testing.T) {
	w := newCashflowWorld(t)
	s := svc()
	s.store = w.store

	for name, id := range map[string]int64{
		"own outlet":     w.own,
		"another outlet": w.other,
		"company level":  w.companyLv,
	} {
		t.Run(name, func(t *testing.T) {
			if _, err := s.GetCashflow(platformCtx(), id); err != nil {
				t.Errorf("GetCashflow(%d) err = %v, want nil for a platform-wide caller", id, err)
			}
		})
	}
}

// THE FAIL-CLOSED DECISION, asserted as a contract rather than described in a
// comment: a request that reached a cashflow surface with NO store scope at all
// is REFUSED, on every path, and the error does not name the mechanism.
//
// The reasoning is in cashflow_scope.go. The short version: an attached
// platform-wide scope and an ABSENT scope both answer PlatformWide(), because
// both carry no ids — so a surface that must not leak has to ask whether the
// middleware ran, and answer "the middleware never ran" with a denial rather
// than with the company.
func TestCashflowOutletScoping_NoStoreScopeIsRefused(t *testing.T) {
	w := newCashflowWorld(t)
	s := svc()
	s.store = w.store
	// A bare context: no WithStoreScope, which is what an unmounted route, a
	// hand-constructed internal call, or a forgotten middleware produces.
	bare := context.Background()

	paths := map[string]func() error{
		"list": func() error {
			_, _, _, err := s.ListCashflows(bare, CashflowFilter{})
			return err
		},
		"get by id": func() error {
			_, err := s.GetCashflow(bare, w.own)
			return err
		},
		"update": func() error {
			_, err := s.UpdateCashflow(bare, w.own, CashflowInput{
				Type: "income", Category: "other", TransactionDate: "2026-08-12", Amount: decimal.NewFromInt(1),
			})
			return err
		},
		"delete": func() error { return s.DeleteCashflow(bare, w.own) },
		"reverse": func() error {
			_, err := s.ReverseCashflow(bare, w.own, CashflowReverseInput{})
			return err
		},
	}
	for name, call := range paths {
		t.Run(name, func(t *testing.T) {
			err := call()
			if !errors.Is(err, ErrNoStoreScope) {
				t.Fatalf("err = %v, want ErrNoStoreScope (fail closed, not platform-wide)", err)
			}
			// The message must not reach a client: it names the scoping
			// mechanism, which is not the caller's business. The handler maps it
			// to a flat 403; this asserts the sentinel is safe to branch on.
			if err.Error() == "" {
				t.Error("the sentinel has no message to keep off the wire")
			}
		})
	}
}

// A restricted caller with an EMPTY store list is a different case from an
// absent scope, and it fails closed by a different route: the scope is real, it
// just resolves to no stores. Both deny; the distinction matters because only one
// of them is an error.
func TestCashflowOutletScoping_RestrictedWithNoStoresSeesNothing(t *testing.T) {
	w := newCashflowWorld(t)
	s := svc()
	s.store = w.store
	// Attached, restricted, empty: real scope, no stores.
	ctx := outletCtx()

	if _, err := s.GetCashflow(ctx, w.own); !errors.Is(err, ErrNotFound) {
		t.Errorf("err = %v, want ErrNotFound (an empty scope matches nothing, and is not an error)", err)
	}
	if _, _, _, err := s.ListCashflows(ctx, CashflowFilter{}); err != nil {
		t.Errorf("ListCashflows err = %v, want nil (empty result, not an error)", err)
	}
}

// THE MUTATION PATHS. Not among the brief's five reads, and included anyway: a
// write that is not scoped is a read plus a mutation, and the lock these take IS
// the row they are about to change. An unscoped lock would let a till operator
// name an id and overwrite another outlet's amount.
func TestCashflowOutletScoping_MutationPathsAreScoped(t *testing.T) {
	w := newCashflowWorld(t)
	s := svc()
	s.store = w.store
	ctx := outletCtx(1)

	desc := "clobbered"
	if _, err := s.UpdateCashflow(ctx, w.other, CashflowInput{
		Type: "expense", Category: "other", TransactionDate: "2026-08-12", Amount: decimal.NewFromInt(1), Description: &desc,
	}); !errors.Is(err, ErrNotFound) {
		t.Errorf("UpdateCashflow on another outlet = %v, want ErrNotFound", err)
	}
	if err := s.DeleteCashflow(ctx, w.other); !errors.Is(err, ErrNotFound) {
		t.Errorf("DeleteCashflow on another outlet = %v, want ErrNotFound", err)
	}
	// The row must be untouched, not merely reported as an error.
	if got := w.store.cashflows[w.other].Amount; !got.Equal(decimal.NewFromInt(99)) {
		t.Errorf("another outlet's amount is now %s, want 99 (a refused mutation must not write)", got)
	}
	if _, ok := w.store.cashflows[w.other]; !ok {
		t.Error("another outlet's cashflow was deleted by a refused delete")
	}
}

// THE WRITE PATH. The outlet comes from the caller's context and from nowhere
// else. A body that could name its own outlet would write rows into a store the
// caller does not own and then read them back through every filter in this file.
func TestCashflowOutletScoping_WriteDerivesOutletFromContext(t *testing.T) {
	for _, tc := range []struct {
		name string
		ctx  context.Context
		want *int64
	}{
		// A restricted single-store caller: the store is IMPLIED. This is the
		// till operator's case and the reason the ERP's Arus Kas Kasir screen
		// keeps working.
		{"till operator", outletCtx(1), ptrInt64(1)},
		// A platform-wide caller has no outlet of its own, so the row is
		// company-level — the correct reading of "the company booked this".
		{"platform-wide", platformCtx(), nil},
	} {
		t.Run(tc.name, func(t *testing.T) {
			s := svc()
			got, err := s.CreateCashflow(tc.ctx, CashflowInput{
				Type: "expense", Category: "operational", TransactionDate: "2026-08-11",
				Amount: decimal.NewFromInt(50), AccountID: ptrInt64(1),
			})
			if err != nil {
				t.Fatal(err)
			}
			switch {
			case tc.want == nil && got.StoreID != nil:
				t.Errorf("store_id = %d, want NULL (company-level)", *got.StoreID)
			case tc.want != nil && got.StoreID == nil:
				t.Errorf("store_id = NULL, want %d", *tc.want)
			case tc.want != nil && *got.StoreID != *tc.want:
				t.Errorf("store_id = %d, want %d", *got.StoreID, *tc.want)
			}
			// Read it back through the STORE, so the assertion is about what was
			// persisted rather than about the struct the service returned.
			if st := s.store.(*fakeStore); st.cashflows[got.ID].StoreID == nil != (tc.want == nil) {
				t.Errorf("persisted store_id = %v, want %v", st.cashflows[got.ID].StoreID, tc.want)
			}
		})
	}
}

// A client cannot choose the outlet, because there is nowhere to put one. The
// input type is the contract, and this asserts the BEHAVIOUR end to end through
// the real handler rather than asserting a compile-time absence — the risk is a
// field being added later, and an absence check would not notice.
//
// The body names outlet 2 three ways; the persisted row must belong to the
// caller's own outlet 1. Keys the input type does not have are dropped by the
// handler's non-strict decode, which is exactly the property relied on here.
func TestCashflowOutletScoping_BodyCannotChooseTheOutlet(t *testing.T) {
	s := svc()
	body := `{"type":"expense","category":"operational","transaction_date":"2026-08-11",` +
		`"amount":50,"account_id":1,"store_id":2,"storeId":2,"outlet_id":2,"store":2}`
	req := httptest.NewRequest(http.MethodPost, "/cashflows", strings.NewReader(body)).
		WithContext(outletCtx(1))
	res := httptest.NewRecorder()
	NewHandler(s).CreateCashflow(res, req)
	if res.Code != http.StatusCreated {
		t.Fatalf("status = %d body %s, want 201", res.Code, res.Body.String())
	}

	st := s.store.(*fakeStore)
	if len(st.cashflows) != 1 {
		t.Fatalf("stored %d cashflows, want 1", len(st.cashflows))
	}
	var got *Cashflow
	for _, c := range st.cashflows {
		got = c
	}
	if got.StoreID == nil || *got.StoreID != 1 {
		t.Errorf("persisted store_id = %v, want 1 — the body's 2 must be ignored", got.StoreID)
	}
	// And the body must not have leaked into the response as an outlet either.
	if strings.Contains(res.Body.String(), `"store_id":2`) {
		t.Errorf("response echoed the client's outlet: %s", res.Body.String())
	}
}

// A restricted MULTI-store caller cannot write at all, because nil cannot be
// resolved to a single store. That is ResolveStoreBinding's existing contract
// and it is the right one here: guessing which of a caller's several outlets
// meant would put the row somewhere they might never look, and a cashflow row
// nobody can find is worse than a refused one.
func TestCashflowOutletScoping_MultiStoreCallerCannotGuess(t *testing.T) {
	s := svc()
	_, err := s.CreateCashflow(outletCtx(1, 2, 3), CashflowInput{
		Type: "expense", Category: "operational", TransactionDate: "2026-08-11",
		Amount: decimal.NewFromInt(50), AccountID: ptrInt64(1),
	})
	if !errors.Is(err, ErrForbidden) {
		t.Errorf("err = %v, want ErrForbidden (a multi-store caller must supply a binding, and this surface has none)", err)
	}
}

// THE FIVE PATHS, AS DATA. The brief's own instruction was that this table is
// the one most likely to look green while being vacuous, so it is asserted
// three ways: every listed path is named here, the paths this file exercises
// are a superset of the ones the migration lists, and the migration's list
// itself has not silently changed.
func TestCashflowOutletScoping_AllReadPathsAreScoped(t *testing.T) {
	// The paths 000104 §4 says must be scoped. AccountBalances is IN the list
	// because the migration lists it — and the migration marks it explicitly as
	// not a cashflow read. Both halves matter: a route silently dropped from the
	// documentation is how a future edit re-opens the hole, and a route
	// documented as secured while it is not is worse.
	documented := []string{
		"GET /cashflows/",
		"GET /cashflows/{id}",
		"GET /cashflow-balances/",
		"GET /cashflow-balances/accounts",
		"GET /reports/.../cashflow",
	}
	// The paths this lane actually scoped. AccountBalances is deliberately
	// absent, and the reason is asserted rather than only commented: it
	// aggregates journal_lines, not cashflows, so there is no column to filter.
	scoped := []string{
		"GET /cashflows/",
		"GET /cashflows/{id}",
		"GET /cashflow-balances/",
		"GET /reports/.../cashflow",
	}
	// And the one documented-but-not-scoped must be named exactly, so if the
	// migration's list ever changes the discrepancy shows up as a name rather
	// than as a count.
	notScoped := []string{"GET /cashflow-balances/accounts"}
	if len(scoped)+len(notScoped) != len(documented) {
		t.Fatalf("this file scopes %d of the %d documented paths, and names %d as deliberately "+
			"unscoped; the two must account for every documented route",
			len(scoped), len(documented), len(notScoped))
	}
	for _, ns := range notScoped {
		if !contains(documented, ns) {
			t.Errorf("%q is named here as unscoped but is not in migration 000104 §4's list — "+
				"either the migration or this file is out of date", ns)
		}
		if contains(scoped, ns) {
			t.Errorf("%q is both scoped and unscoped here", ns)
		}
	}
	for _, want := range scoped {
		found := false
		for _, got := range documented {
			if got == want {
				found = true
			}
		}
		if !found {
			t.Errorf("%q is scoped here but is not in migration 000104 §4's list", want)
		}
	}
	// And the mutation paths, which are not in the brief's list at all.
	for _, extra := range []string{"PUT /cashflows/{id}", "DELETE /cashflows/{id}", "POST /cashflows/{id}/reverse"} {
		if contains(documented, extra) {
			t.Errorf("%q appears in the documented list; the brief listed reads only, and the "+
				"mutation paths are a deliberate addition", extra)
		}
	}
}

func contains(list []string, s string) bool {
	for _, v := range list {
		if v == s {
			return true
		}
	}
	return false
}
