package reports

// Outlet scoping for the cashflow export (migration 000104), READ PATH 5 of 5.
//
// The brief's own warning applies hardest here: cashier holds
// finance.cashflows.export (000080:17-28), so the CSV was a company-wide
// cashflow dump, and it leaves as a FILE rather than a paginated JSON body. A
// scoped list with an unscoped export is a list with a download button.

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

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

func platformCtx() context.Context {
	return auth.WithStoreScope(context.Background(), auth.NewStoreScope(nil))
}

func outletCtx(ids ...int64) context.Context {
	// Explicit slice, not a variadic one: a variadic called with no arguments is
	// nil, and nil means platform-wide. The identical trap and the identical fix
	// as finance.outletCtx; see the comment there for why it is written this way.
	out := make([]int64, 0, len(ids))
	out = append(out, ids...)
	return auth.WithStoreScope(context.Background(), auth.NewStoreScope(out))
}

// recordedScope is the last ReportFilter the repository received, so a test can
// assert the scope ARRIVED at the query rather than only that it was computed.
type recordedScope struct {
	set bool
	ids []int64
}

func newScopedStore(ids ...int64) (*fakeStore, *recordedScope) {
	rec := &recordedScope{}
	st := &fakeStore{rows: []CSVRow{}}
	st.cashflowScope = func(f ReportFilter) { rec.set, rec.ids = true, f.StoreIDs }
	return st, rec
}

// READ PATH 5. A restricted caller's export carries their own outlet's ids into
// the query, and a platform-wide caller's carries nil — the two are different
// values with opposite meanings, which is what the SQL's `IS NULL` arm keys on.
func TestCashflowExport_ScopeReachesTheQuery(t *testing.T) {
	for _, tc := range []struct {
		name string
		ctx  context.Context
		want []int64
	}{
		{"till operator", outletCtx(1), []int64{1}},
		{"multi-outlet operator", outletCtx(4, 6), []int64{4, 6}},
		// nil is platform-wide and is NOT the same as empty: pgx sends a nil
		// slice as SQL NULL (matches everything, including company-level rows)
		// and an empty slice as '{}' (matches nothing).
		{"platform-wide", platformCtx(), nil},
		{"restricted to nothing", outletCtx(), []int64{}},
	} {
		t.Run(tc.name, func(t *testing.T) {
			st, rec := newScopedStore()
			h := NewHandler(NewService(st, loc()))

			req := httptest.NewRequest(http.MethodGet, "/api/v1/reports/cashflow", nil).WithContext(tc.ctx)
			rr := httptest.NewRecorder()
			h.ExportCashflow(rr, req)

			if rr.Code != http.StatusOK {
				t.Fatalf("status = %d body %s, want 200", rr.Code, rr.Body.String())
			}
			if !rec.set {
				t.Fatal("the repository was never called, so no scope was passed")
			}
			if len(rec.ids) != len(tc.want) {
				t.Fatalf("StoreIDs = %v, want %v", rec.ids, tc.want)
			}
			for i := range rec.ids {
				if rec.ids[i] != tc.want[i] {
					t.Fatalf("StoreIDs = %v, want %v", rec.ids, tc.want)
				}
			}
		})
	}
}

// The export's own parameters must not become an outlet selector. A
// ?store_id= that narrowed or widened the export would be a scope field reading
// the query string, which is the mistake this lane closed on the write path.
func TestCashflowExport_QueryStringCannotChooseTheOutlet(t *testing.T) {
	st, rec := newScopedStore()
	h := NewHandler(NewService(st, loc()))

	req := httptest.NewRequest(http.MethodGet,
		"/api/v1/reports/cashflow?store_id=2&storeId=2&outlet_id=2", nil).WithContext(outletCtx(1))
	rr := httptest.NewRecorder()
	h.ExportCashflow(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d body %s, want 200", rr.Code, rr.Body.String())
	}
	if len(rec.ids) != 1 || rec.ids[0] != 1 {
		t.Errorf("StoreIDs = %v, want [1] — the query string must not reach the outlet scope", rec.ids)
	}
}

// THE FAIL-CLOSED DECISION, on the export. A request with no store scope at all
// is refused, and the message does not name the mechanism. 403 rather than 404
// because the caller is authenticated and this resource class is known to it.
func TestCashflowExport_NoStoreScopeIsRefused(t *testing.T) {
	st, rec := newScopedStore()
	h := NewHandler(NewService(st, loc()))

	// A bare context: no WithStoreScope, which is what an unmounted route or a
	// forgotten middleware produces.
	req := httptest.NewRequest(http.MethodGet, "/api/v1/reports/cashflow", nil).
		WithContext(context.Background())
	rr := httptest.NewRecorder()
	h.ExportCashflow(rr, req)

	if rr.Code != http.StatusForbidden {
		t.Fatalf("status = %d body %s, want 403 (fail closed, not platform-wide)", rr.Code, rr.Body.String())
	}
	if rec.set {
		t.Error("the repository was called despite the refusal: the query ran without a scope")
	}
	body := rr.Body.String()
	for _, leak := range []string{"no store scope", "ErrNoStoreScope", "cashflowStoreScope", "store_ids"} {
		if strings.Contains(body, leak) {
			t.Errorf("the response leaks the scoping mechanism (%q): %s", leak, body)
		}
	}
}

// The other two exports read tables with no store dimension, so they must be
// UNCHANGED by this lane. A regression here would break a finance user's report
// by attaching a filter it cannot honour — and, worse, would look like the
// report had been secured.
func TestCashflowExport_OtherExportsAreUnaffected(t *testing.T) {
	for name, kind := range map[string]RowType{"receivables": RowReceivable, "debts": RowDebt} {
		t.Run(name, func(t *testing.T) {
			// No cashflowScope hook, so a call to the cashflow path would be
			// visible as data rather than as a nil field.
			st := &fakeStore{rows: []CSVRow{}}
			// A caller restricted to one outlet: if these exports consulted the
			// scope at all, they would try to filter on a store_id their tables
			// do not have.
			if _, err := NewService(st, loc()).Export(outletCtx(1), kind, ReportFilter{}); err != nil {
				t.Fatalf("err = %v", err)
			}
			// Their gates are finance.* and a finance user's report must keep
			// working; what must NOT happen is a silent narrowing.
			if st.lastExportFilter != nil {
				t.Errorf("the %s export received a filter; it reads no cashflows and has no store dimension", name)
			}
		})
	}
}

// reports duplicates finance's scope resolver on purpose — see the header of
// cashflow_scope.go. Duplication is only safe while the two agree, so the
// agreement is asserted rather than assumed. A drift would show up here instead
// of as a report that quietly returns a different set from the screen.
func TestCashflowStoreScopeMatchesFinance(t *testing.T) {
	for _, tc := range []struct {
		name    string
		ctx     context.Context
		wantIDs []int64
		wantErr error
	}{
		// nil is the ONLY value that means "sees everything". Collapsing it into
		// an empty slice would silently make a platform-wide caller see nothing —
		// the opposite failure, and just as bad.
		{"platform-wide", platformCtx(), nil, nil},
		{"restricted", outletCtx(1, 2), []int64{1, 2}, nil},
		{"restricted to nothing", outletCtx(), []int64{}, nil},
		// The absent-scope case is the one that must FAIL CLOSED. If this copy
		// defaulted to platform-wide, a request that never passed through
		// ScopeMiddleware would read the whole company — the exact failure
		// finance.cashflowStoreScope was written to prevent, reintroduced by
		// duplicating it.
		{"absent", context.Background(), nil, ErrNoStoreScope},
	} {
		t.Run(tc.name, func(t *testing.T) {
			ids, err := cashflowStoreScope(tc.ctx)
			if tc.wantErr != nil {
				if !errors.Is(err, tc.wantErr) {
					t.Fatalf("err = %v, want %v (fail closed, not platform-wide)", err, tc.wantErr)
				}
				if ids != nil {
					t.Errorf("ids = %v, want nil alongside an error", ids)
				}
				return
			}
			if err != nil {
				t.Fatalf("err = %v", err)
			}
			if len(ids) != len(tc.wantIDs) {
				t.Fatalf("ids = %v, want %v", ids, tc.wantIDs)
			}
			for i := range ids {
				if ids[i] != tc.wantIDs[i] {
					t.Fatalf("ids = %v, want %v", ids, tc.wantIDs)
				}
			}
		})
	}
}
