package integration

import (
	"context"
	"os"
	"testing"

	"github.com/jackc/pgx/v5/pgxpool"
)

// readOnlyPool exists because setUp() and testPool() truncate ~40 tables, so
// they sit behind KG_ALLOW_DESTRUCTIVE_DB_TESTS. The matrix assertions below
// only SELECT, and gating them behind destructive consent would make them skip
// in exactly the environments where a stale matrix is easiest to miss.
func readOnlyPool(t *testing.T) *pgxpool.Pool {
	t.Helper()
	databaseURL := os.Getenv("DATABASE_URL")
	if databaseURL == "" {
		t.Skip("DATABASE_URL is not set")
	}
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, databaseURL)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(pool.Close)
	return pool
}

func hasPerm(t *testing.T, pool *pgxpool.Pool, role, perm string) bool {
	t.Helper()
	var n int
	if err := pool.QueryRow(context.Background(),
		`SELECT count(*) FROM role_permissions WHERE role = $1 AND permission = $2`,
		role, perm).Scan(&n); err != nil {
		t.Fatal(err)
	}
	return n > 0
}

// Migration 000131 extends sales read access to visits and LHPP. Do not pin
// the superseded 000060/000110 exclusions here: the persisted grant matrix is
// administrable policy, not a substitute for service authorization checks.

// The visit-schedule grants were inserted by 000060_visit_schedule_rbac.sql and
// then deleted one revision later by 000061_rbac_launch_reset.sql
// (`DELETE FROM role_permissions;`). That wipe was intentional — it retired the
// seeded 'sales_tracking.*' globs — but nothing put 000060's rows back, so
// every /sales-tracking/visits/* endpoint answered 403 for every role but
// admin. A sales rep could not see or check in to their own schedule.
//
// These assertions fail against that state, which is the point: the deletion
// was silent. Nothing errored, no migration failed, the matrix simply had no
// rows for the feature and the feature simply stopped existing for everyone.
func TestVisitScheduleGrantsArePresent(t *testing.T) {
	pool := readOnlyPool(t)

	for _, tc := range []struct {
		role, perm string
	}{
		// A rep works their own schedule and checks in and out. Per the client
		// revision at the top of 000060, they never author one.
		{"sales", "sales_tracking.visits.view_own"},
		{"sales", "sales_tracking.visits.checkin"},
		{"sales", "sales_tracking.visits.checkout"},
		// SPV schedules routine visits.
		{"spv", "sales_tracking.visits.view"},
		{"spv", "sales_tracking.visits.create"},
		{"spv", "sales_tracking.visits.create.routine"},
		{"spv", "sales_tracking.visits.checkin"},
		{"spv", "sales_tracking.visits.checkout"},
		{"spv", "sales_tracking.visits.view_own"},
		// Finance (Admin Piutang) schedules billing/collection visits.
		{"finance", "sales_tracking.visits.view"},
		{"finance", "sales_tracking.visits.create"},
		{"finance", "sales_tracking.visits.create.billing"},
		{"finance", "sales_tracking.visits.view_own"},
		{"finance_head", "sales_tracking.visits.view"},
		{"finance_head", "sales_tracking.visits.create"},
		{"finance_head", "sales_tracking.visits.create.billing"},
		{"finance_head", "sales_tracking.visits.view_own"},
	} {
		if !hasPerm(t, pool, tc.role, tc.perm) {
			t.Errorf("role %q is missing %q — the endpoint behind it answers 403 for everyone "+
				"except admin/owner, who bypass on the \"*\" wildcard", tc.role, tc.perm)
		}
	}
}

// The POS "add customer" flow is two requests: createCustomer, then
// createMembership. They are gated by different permissions, and the roles were
// not kept in step — 'sales' held masterdata.customers.create (from the
// masterdata.* glob 000058 expanded) but not masterdata.memberships.create, so a
// sales rep could register a customer and then not enrol them. The frontend
// catches the 403 and reports "Pelanggan dibuat, member gagal", which is what
// makes a missing enrolment look like a non-event.
//
// Asserted as a property over the matrix rather than as a list of expected
// roles, because the failure mode is precisely a role that drifted: naming them
// would need editing every time a role is added, and this is the check that
// would have to be remembered in order to fail to fire. Any role able to create
// a customer must be able to finish the enrolment, or the flow is half-broken
// for that role.
func TestRolesThatCreateCustomersCanEnrolThem(t *testing.T) {
	pool := readOnlyPool(t)

	rows, err := pool.Query(context.Background(),
		`SELECT DISTINCT role FROM role_permissions
		 WHERE permission = 'masterdata.customers.create' ORDER BY role`)
	if err != nil {
		t.Fatal(err)
	}
	defer rows.Close()
	var roles []string
	for rows.Next() {
		var r string
		if err := rows.Scan(&r); err != nil {
			t.Fatal(err)
		}
		roles = append(roles, r)
	}
	if err := rows.Err(); err != nil {
		t.Fatal(err)
	}
	if len(roles) == 0 {
		t.Fatal("no role can create customers — the POS add-customer flow has no entry point")
	}

	// Traced from the routes the POS membership surface calls: POST / needs
	// create; GET /tiers and /me need read; GET / and /by-customer/{id} need
	// lookup. Granting create alone would leave the rep unable to look up the
	// member they just made, which is the same bug one step later.
	for _, role := range roles {
		for _, perm := range []string{
			"masterdata.memberships.create",
			"masterdata.memberships.read",
			"masterdata.memberships.lookup",
		} {
			if !hasPerm(t, pool, role, perm) {
				t.Errorf("role %q can create a customer but not %q — the POS flow registers the "+
					"customer and enrols them as two calls, so the second fails after the first "+
					"has already succeeded", role, perm)
			}
		}
	}
}

// Enrolling someone is implied by being allowed to register them. Repricing a
// tier and accruing points are separate decisions and must not leak into the
// roles this file grants.
//
// It does NOT tell you who holds masterdata.memberships.tiers.manage,
// because that used to be an open question. Migration 000112 records the
// answer: GM and store manager, with admin/owner already reaching it through
// the wildcard. 000105 had walked into the same question and stopped on
// purpose — "deleting the two that cost something is a business decision
// about whether a till may edit membership pricing, and this migration does
// not make that decision silently" — so before 000112 the honest assertion
// was only the negative one below.
func TestMembershipTierAndPointsDoNotLeakIntoFrontlineRoles(t *testing.T) {
	pool := readOnlyPool(t)

	for _, role := range []string{"sales", "cashier"} {
		for _, perm := range []string{
			"masterdata.memberships.manage",
			"masterdata.memberships.tiers.manage",
		} {
			if hasPerm(t, pool, role, perm) {
				t.Errorf("role %q holds %q — enrolling a walk-in customer does not imply editing "+
					"points or repricing a tier", role, perm)
			}
		}
	}
}

// 000112: repricing a tier is GM and store manager, admin/owner via the
// wildcard. Pinned so the grant cannot be dropped silently, and so the
// asymmetry with memberships.manage (store_manager only) stays visible: the
// store manager reprices a tier even though only GM changes a member's tier.
func TestMembershipTierPricingIsHeldByGMAndStoreManager(t *testing.T) {
	pool := readOnlyPool(t)

	for _, role := range []string{"gm", "store_manager"} {
		if !hasPerm(t, pool, role, "masterdata.memberships.tiers.manage") {
			t.Errorf("role %q lost masterdata.memberships.tiers.manage — POST/PUT /memberships/tiers "+
				"answers 403 for it again", role)
		}
	}
	// Points accrual stays narrower than tier pricing: only the store manager.
	for _, role := range []string{"gm", "cashier", "sales"} {
		if hasPerm(t, pool, role, "masterdata.memberships.manage") {
			t.Errorf("role %q holds masterdata.memberships.manage — points accrual is store_manager only", role)
		}
	}
	// 000113: a pricing editor must be able to see the catalogue it edits.
	// GET /memberships/tiers and /tiers/{id} are gated on .read, so gm with
	// only .tiers.manage could set what a tier is worth and not read it back.
	for _, role := range []string{"gm", "store_manager"} {
		if !hasPerm(t, pool, role, "masterdata.memberships.read") {
			t.Errorf("role %q holds tiers.manage but not .read — it can edit a tier it cannot list", role)
		}
	}
	// The member DIRECTORY stays off .read on purpose, so it must not ride
	// along with the catalogue grant.
	for _, role := range []string{"gm", "cashier", "sales"} {
		if role == "cashier" || role == "sales" {
			continue // these hold .lookup legitimately as frontline roles
		}
		if hasPerm(t, pool, role, "masterdata.memberships.lookup") {
			t.Errorf("role %q holds masterdata.memberships.lookup — the member directory is "+
				"internal only and is kept separate from .read on purpose", role)
		}
	}
}

// The B2B checkout has a voucher field, and the storefront validates the code
// there for instant feedback before submit. Every buyer got 403: the route is
// gated on sales.sales-vouchers.validate, which cashier and sales held, and
// which buyer did not. So the feature worked at the counter and for nobody it
// was written for.
//
// The gate itself is right and deliberately narrower than the list — the route
// comment records the 2026-08-25 audit: "redemption lookups use a dedicated key
// so customer accounts can validate a code without gaining the company-wide
// voucher list". So the grant is added, not the gate widened.
func TestBuyerValidatesVoucherWithoutGainingTheList(t *testing.T) {
	pool := readOnlyPool(t)

	if !hasPerm(t, pool, "buyer", "sales.sales-vouchers.validate") {
		t.Error("role \"buyer\" cannot validate a voucher code — the B2B checkout voucher field " +
			"answers 403 for every customer")
	}
	// The separation the audit was after: redeemable, but no company-wide list.
	if hasPerm(t, pool, "buyer", "sales.sales-vouchers.view") {
		t.Error("role \"buyer\" holds sales.sales-vouchers.view — that lists every voucher in the " +
			"company, including codes nobody has been issued")
	}
	// And nothing that could mark a voucher used from the storefront.
	for _, perm := range []string{
		"sales.sales-vouchers.create",
		"sales.sales-vouchers.update",
		"sales.sales-vouchers.delete",
	} {
		if hasPerm(t, pool, "buyer", perm) {
			t.Errorf("role \"buyer\" holds %q — redemption is server-authoritative inside the "+
				"checkout transaction, not something a client may drive", perm)
		}
	}
}

// The POS prints the store's name, address and phone on every receipt
// (components/pos/ReceiptPreview.vue reads appStore.storeInfo.*). A cashier
// calling GET /api/v1/settings got 403 — system.settings.view was store_manager
// alone — and settingsService.getSettings swallowed it and fell back to
// device-local defaults carrying a Jakarta address and a 021 landline, for a
// Medan store. Receipts went out wrong and the console red line was the only
// clue.
func TestCashierCanReadStoreSettingsForReceipts(t *testing.T) {
	pool := readOnlyPool(t)

	if !hasPerm(t, pool, "cashier", "system.settings.view") {
		t.Error("role \"cashier\" cannot read store settings — the POS falls back to local " +
			"defaults, so every receipt prints a blank store identity")
	}
	// Reading the store profile is not editing the price list.
	for _, perm := range []string{"system.settings.manage"} {
		if hasPerm(t, pool, "cashier", perm) {
			t.Errorf("role \"cashier\" holds %q — writing the price list stays with store_manager", perm)
		}
	}
}

// The chart of accounts carries two parallel sets of cash and bank accounts:
//
//	1000.01.01  Kas Tunai Toko                 used, 6 journal lines
//	1000.03.01  Bank BCA Rekening Perusahaan   used, 2 journal lines
//	1010        Kas Utama                      empty
//	1020        Kas Kecil                      empty
//	1100        Bank BCA                       empty
//	1110        Bank Mandiri                    empty
//
// 1100 and 1000.03.01 are both "Bank BCA", 1110 and 1000.03.02 are both
// "Bank Mandiri", 1010 and 1000.01.01 are both store tills. A report that sums
// "cash" by name has a plausible way to count the same money twice, and a
// balance sheet that double-counts is wrong quietly.
//
// 000116 makes the 1000.x set canonical and the other four inactive. Asserted
// here so the two sets cannot both come back: a future seed re-adding them
// active would put the ambiguity straight back.
func TestDuplicateCashAccountsAreInactive(t *testing.T) {
	pool := readOnlyPool(t)

	for _, code := range []string{"1010", "1020", "1100", "1110"} {
		var active bool
		if err := pool.QueryRow(context.Background(),
			`SELECT is_active FROM accounts WHERE code = $1`, code).Scan(&active); err != nil {
			t.Fatalf("account %s: %v", code, err)
		}
		if active {
			t.Errorf("account %s is active — it duplicates a 1000.x account and a cash total "+
				"that includes both is double-counting", code)
		}
	}
	// The canonical pair must stay active, or cash disappears from reports.
	for _, code := range []string{"1000.01.01", "1000.03.01"} {
		var active bool
		if err := pool.QueryRow(context.Background(),
			`SELECT is_active FROM accounts WHERE code = $1`, code).Scan(&active); err != nil {
			t.Fatalf("account %s: %v", code, err)
		}
		if !active {
			t.Errorf("canonical cash account %s is inactive — reports would drop real money", code)
		}
	}
	// The two 1000.x accounts that are legitimately empty are canonical, not
	// duplicates, and an ordinary unused account is not a fault.
	for _, code := range []string{"1000.01.02", "1000.03.02"} {
		var active bool
		if err := pool.QueryRow(context.Background(),
			`SELECT is_active FROM accounts WHERE code = $1`, code).Scan(&active); err != nil {
			t.Fatalf("account %s: %v", code, err)
		}
		if !active {
			t.Errorf("account %s is inactive — it is a canonical account with no activity yet, "+
				"which is an ordinary state and not a duplicate", code)
		}
	}
}
