package scenarios_test

// Security scenario tests for RLS (Record-Level Security).
//
// These verify that the RLS SQL generation uses correct column names
// against real database tables, and that ControlledByParent sharing
// model works consistently between single-record and bulk query paths.
//
// Prerequisites:
//   - A non-admin user (sales@test.com) must exist with Sales profile
//   - An admin user must exist for setup operations
//   - Server must be running with real database

import (
	"encoding/json"
	"fmt"
	"net/http"
	"testing"
	"time"

	"github.com/nexuscrm/tests/contract/internal/liveapi"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// ---------- SEC-001: Manual share RLS uses correct column names ----------

func TestSEC001_ManualShareRLSColumnNames(t *testing.T) {
	// Scenario: Create a RecordShare entry granting a non-admin user access
	// to a specific Account record. Then query as that user. If rls_filter.go
	// uses wrong column names (SobjectType instead of ObjectApiName,
	// UserOrGroupId instead of ShareWithUserId), TiDB returns "Unknown column".
	//
	// Evidence: rls_filter.go:294 uses rs.SobjectType and rs.UserOrGroupId
	//           but _System_RecordShare has ObjectApiName, ShareWithUserId, ShareWithGroupId

	admin := adminClient(t)
	sales := salesClient(t)

	// Step 1: Create an Account owned by admin
	acctName := fmt.Sprintf("SEC001-%d", time.Now().UnixNano())
	resp, err := admin.DoJSON(http.MethodPost, "/sobjects/Account", map[string]any{
		"Name": acctName,
	})
	require.NoError(t, err)
	defer resp.Body.Close()
	body := liveapi.ReadBody(t, resp)
	require.Equal(t, http.StatusCreated, resp.StatusCode, "Admin create Account: %s", body)

	var created struct{ ID string `json:"id"` }
	require.NoError(t, json.Unmarshal(body, &created))
	t.Cleanup(func() {
		r, _ := admin.Do(http.MethodDelete, "/sobjects/Account/"+created.ID, "", nil)
		if r != nil { r.Body.Close() }
	})

	// Step 2: Create a manual RecordShare granting Sales user Read access
	shareResp, err := admin.DoJSON(http.MethodPost, "/sobjects/_System_RecordShare", map[string]any{
		"ObjectApiName":    "Account",
		"RecordId":         created.ID,
		"ShareWithUserId":  salesUserID(t, admin),
		"AccessLevel":      "Read",
		"Reason":           "Manual",
	})
	require.NoError(t, err)
	defer shareResp.Body.Close()
	shareBody := liveapi.ReadBody(t, shareResp)
	t.Logf("RecordShare create response (%d): %s", shareResp.StatusCode, shareBody)
	require.Equal(t, http.StatusCreated, shareResp.StatusCode,
		"RecordShare must be created for this test to be valid: %s", shareBody)

	// Step 3: As Sales user, query accounts — this triggers RLS with manual share subquery
	// If column names are wrong, this will return 500 (SQL error) instead of results
	queryResp, err := sales.Do(http.MethodGet,
		fmt.Sprintf("/query?q=SELECT+Id,Name+FROM+Account+WHERE+Id='%s'", created.ID),
		"", nil)
	require.NoError(t, err)
	defer queryResp.Body.Close()
	queryBody := liveapi.ReadBody(t, queryResp)

	// Must NOT be 500 — that indicates SQL column name mismatch
	require.NotEqual(t, http.StatusInternalServerError, queryResp.StatusCode,
		"SECURITY BUG: RLS manual share query hit SQL error (likely wrong column names): %s", queryBody)

	// Should be 200 with the shared record visible
	assert.Equal(t, http.StatusOK, queryResp.StatusCode,
		"Sales user should see manually shared Account: %s", queryBody)
}

// ---------- SEC-002: Team member RLS uses correct column names ----------

func TestSEC002_TeamMemberRLSColumnNames(t *testing.T) {
	// Scenario: Create a TeamMember entry for a non-admin user on an Account.
	// Query as that user. If rls_filter.go:304 uses tm.SobjectType instead
	// of tm.ObjectApiName, TiDB returns "Unknown column".
	//
	// Evidence: rls_filter.go:304 uses tm.SobjectType
	//           but _System_TeamMember has ObjectApiName

	admin := adminClient(t)
	sales := salesClient(t)

	acctName := fmt.Sprintf("SEC002-%d", time.Now().UnixNano())
	resp, err := admin.DoJSON(http.MethodPost, "/sobjects/Account", map[string]any{
		"Name": acctName,
	})
	require.NoError(t, err)
	defer resp.Body.Close()
	body := liveapi.ReadBody(t, resp)
	require.Equal(t, http.StatusCreated, resp.StatusCode, "Admin create Account: %s", body)

	var created struct{ ID string `json:"id"` }
	require.NoError(t, json.Unmarshal(body, &created))
	t.Cleanup(func() {
		r, _ := admin.Do(http.MethodDelete, "/sobjects/Account/"+created.ID, "", nil)
		if r != nil { r.Body.Close() }
	})

	// Add Sales user as team member
	tmResp, err := admin.DoJSON(http.MethodPost, "/sobjects/_System_TeamMember", map[string]any{
		"ObjectApiName": "Account",
		"RecordId":      created.ID,
		"UserId":        salesUserID(t, admin),
		"TeamRole":      "Team Member",
		"AccessLevel":   "Read",
	})
	require.NoError(t, err)
	defer tmResp.Body.Close()
	tmBody := liveapi.ReadBody(t, tmResp)
	t.Logf("TeamMember create response (%d): %s", tmResp.StatusCode, tmBody)
	require.Equal(t, http.StatusCreated, tmResp.StatusCode,
		"TeamMember must be created for this test to be valid: %s", tmBody)

	// Query as Sales user — triggers RLS team member subquery
	queryResp, err := sales.Do(http.MethodGet,
		fmt.Sprintf("/query?q=SELECT+Id,Name+FROM+Account+WHERE+Id='%s'", created.ID),
		"", nil)
	require.NoError(t, err)
	defer queryResp.Body.Close()
	queryBody := liveapi.ReadBody(t, queryResp)

	require.NotEqual(t, http.StatusInternalServerError, queryResp.StatusCode,
		"SECURITY BUG: RLS team member query hit SQL error (likely wrong column names): %s", queryBody)

	assert.Equal(t, http.StatusOK, queryResp.StatusCode,
		"Sales user should see Account via team membership: %s", queryBody)
}

// ---------- SEC-003: ControlledByParent single-record access ----------

func TestSEC003_ControlledByParentSingleRecord(t *testing.T) {
	// Scenario: Contact has SharingModel=ControlledByParent (inherits from Account).
	// Admin creates an Account (shared with Sales user) and a Contact under it.
	// Sales user should be able to GET the Contact by ID (inheriting Account access).
	//
	// Without fix: CheckRecordAccess has no ControlledByParent case, so Contact
	// falls through to ownership check → access denied (Sales doesn't own Contact).
	// Bulk query path (BuildRLSResult) handles ControlledByParent correctly.
	//
	// Evidence: record_access.go:28-38 switch has no ControlledByParent case.

	admin := adminClient(t)
	sales := salesClient(t)
	salesID := salesUserID(t, admin)

	// Create Account owned by admin, shared with Sales
	acctName := fmt.Sprintf("SEC003-Acct-%d", time.Now().UnixNano())
	acctResp, err := admin.DoJSON(http.MethodPost, "/sobjects/Account", map[string]any{
		"Name": acctName,
	})
	require.NoError(t, err)
	defer acctResp.Body.Close()
	acctBody := liveapi.ReadBody(t, acctResp)
	require.Equal(t, http.StatusCreated, acctResp.StatusCode, "create Account: %s", acctBody)

	var acct struct{ ID string `json:"id"` }
	require.NoError(t, json.Unmarshal(acctBody, &acct))

	// Share Account with Sales user
	admin.DoJSON(http.MethodPost, "/sobjects/_System_RecordShare", map[string]any{
		"ObjectApiName":   "Account",
		"RecordId":        acct.ID,
		"ShareWithUserId": salesID,
		"AccessLevel":     "Read",
		"Reason":          "Manual",
	})

	// Create Contact under that Account (owned by admin)
	contactResp, err := admin.DoJSON(http.MethodPost, "/sobjects/Contact", map[string]any{
		"FirstName": "SEC003",
		"LastName":  fmt.Sprintf("Test-%d", time.Now().UnixNano()),
		"AccountId": acct.ID,
	})
	require.NoError(t, err)
	defer contactResp.Body.Close()
	contactBody := liveapi.ReadBody(t, contactResp)
	require.Equal(t, http.StatusCreated, contactResp.StatusCode, "create Contact: %s", contactBody)

	var contact struct{ ID string `json:"id"` }
	require.NoError(t, json.Unmarshal(contactBody, &contact))

	t.Cleanup(func() {
		r, _ := admin.Do(http.MethodDelete, "/sobjects/Contact/"+contact.ID, "", nil)
		if r != nil { r.Body.Close() }
		r, _ = admin.Do(http.MethodDelete, "/sobjects/Account/"+acct.ID, "", nil)
		if r != nil { r.Body.Close() }
	})

	// Sales user GETs the Contact by ID — single-record path (CheckRecordAccess)
	getResp, err := sales.Do(http.MethodGet, "/sobjects/Contact/"+contact.ID, "", nil)
	require.NoError(t, err)
	defer getResp.Body.Close()
	getBody := liveapi.ReadBody(t, getResp)

	// Should succeed: Contact inherits access from parent Account
	assert.Equal(t, http.StatusOK, getResp.StatusCode,
		"Sales user should access Contact via ControlledByParent (Account is shared). Got: %s", getBody)
}

// ---------- SEC-009: Bulk vs single-record consistency ----------

func TestSEC009_BulkVsSingleRecordConsistency(t *testing.T) {
	// Scenario: For the same Contact record, verify:
	//   (a) SOQL query results include it (bulk path)
	//   (b) GET /sobjects/Contact/:id succeeds (single-record path)
	// Both must agree — this is the design's core invariant:
	//   R visible in SOQL ⟺ CheckRecordAccess(U, R, Read) == true
	//
	// This test catches Path A / Path B divergence.

	admin := adminClient(t)
	sales := salesClient(t)
	salesID := salesUserID(t, admin)

	// Setup: Account + Contact, Account shared with Sales
	acctName := fmt.Sprintf("SEC009-Acct-%d", time.Now().UnixNano())
	acctResp, err := admin.DoJSON(http.MethodPost, "/sobjects/Account", map[string]any{"Name": acctName})
	require.NoError(t, err)
	defer acctResp.Body.Close()
	acctBody := liveapi.ReadBody(t, acctResp)
	require.Equal(t, http.StatusCreated, acctResp.StatusCode)
	var acct struct{ ID string `json:"id"` }
	require.NoError(t, json.Unmarshal(acctBody, &acct))

	admin.DoJSON(http.MethodPost, "/sobjects/_System_RecordShare", map[string]any{
		"ObjectApiName": "Account", "RecordId": acct.ID,
		"ShareWithUserId": salesID, "AccessLevel": "Read", "Reason": "Manual",
	})

	contactResp, err := admin.DoJSON(http.MethodPost, "/sobjects/Contact", map[string]any{
		"FirstName": "SEC009", "LastName": fmt.Sprintf("Test-%d", time.Now().UnixNano()),
		"AccountId": acct.ID,
	})
	require.NoError(t, err)
	defer contactResp.Body.Close()
	contactBody := liveapi.ReadBody(t, contactResp)
	require.Equal(t, http.StatusCreated, contactResp.StatusCode)
	var contact struct{ ID string `json:"id"` }
	require.NoError(t, json.Unmarshal(contactBody, &contact))

	t.Cleanup(func() {
		r, _ := admin.Do(http.MethodDelete, "/sobjects/Contact/"+contact.ID, "", nil)
		if r != nil { r.Body.Close() }
		r, _ = admin.Do(http.MethodDelete, "/sobjects/Account/"+acct.ID, "", nil)
		if r != nil { r.Body.Close() }
	})

	// Path A: Bulk query (SOQL)
	qResp, err := sales.Do(http.MethodGet,
		fmt.Sprintf("/query?q=SELECT+Id+FROM+Contact+WHERE+Id='%s'", contact.ID), "", nil)
	require.NoError(t, err)
	defer qResp.Body.Close()
	qBody := liveapi.ReadBody(t, qResp)
	bulkFound := qResp.StatusCode == http.StatusOK

	var qResult struct{ TotalSize int `json:"totalSize"` }
	if bulkFound {
		json.Unmarshal(qBody, &qResult)
		bulkFound = qResult.TotalSize > 0
	}

	// Path B: Single-record GET
	gResp, err := sales.Do(http.MethodGet, "/sobjects/Contact/"+contact.ID, "", nil)
	require.NoError(t, err)
	defer gResp.Body.Close()
	singleFound := gResp.StatusCode == http.StatusOK

	// Both paths must agree
	assert.Equal(t, bulkFound, singleFound,
		"CONSISTENCY VIOLATION: bulk query found=%v, single-record found=%v. "+
			"Path A and Path B must agree per design invariant.", bulkFound, singleFound)
}

// ---------- Helpers ----------

func adminClient(t testing.TB) *liveapi.Client {
	t.Helper()
	return liveapi.NewAuthenticatedClient(t) // default admin auth
}

func salesUserID(t testing.TB, admin *liveapi.Client) string {
	t.Helper()
	resp, err := admin.Do(http.MethodGet,
		"/query?q=SELECT+Id+FROM+User+WHERE+Email='sales@test.com'+AND+IsActive=true",
		"", nil)
	require.NoError(t, err)
	defer resp.Body.Close()
	body := liveapi.ReadBody(t, resp)

	var result struct {
		Records []struct{ ID string `json:"Id"` } `json:"records"`
	}
	require.NoError(t, json.Unmarshal(body, &result))
	if len(result.Records) == 0 {
		t.Skip("sales@test.com not provisioned")
	}
	return result.Records[0].ID
}
