package audit

import (
	"context"
	"database/sql"
	"database/sql/driver"
	"strings"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/nexuscrm/shared/pkg/constants"
)

// stubTxExtractor returns the passed tx (as interface{}) so MaybeEmitUpdateV2
// can observe an "active transaction". Passing nil simulates no-tx.
type stubTxExtractor struct{ tx *sql.Tx }

func (s *stubTxExtractor) ExtractTx(ctx context.Context) interface{} {
	if s.tx == nil {
		return nil
	}
	return s.tx
}

func newSQLMockTx(t *testing.T) (*sql.DB, sqlmock.Sqlmock, *sql.Tx) {
	t.Helper()
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	mock.ExpectBegin()
	tx, err := db.Begin()
	if err != nil {
		t.Fatalf("db.Begin: %v", err)
	}
	return db, mock, tx
}

func TestV2Enabled_DefaultsFalse(t *testing.T) {
	t.Setenv(EnvAuditV2Enabled, "")
	if V2Enabled() {
		t.Fatal("default: want false (opt-in rollout)")
	}
}

func TestV2Enabled_FalseValues(t *testing.T) {
	for _, v := range []string{"false", "FALSE", "0", "no", "", "bogus"} {
		t.Setenv(EnvAuditV2Enabled, v)
		if V2Enabled() {
			t.Errorf("%q: want false", v)
		}
	}
}

func TestV2Enabled_TrueValues(t *testing.T) {
	for _, v := range []string{"true", "TRUE", "1", "yes", "on"} {
		t.Setenv(EnvAuditV2Enabled, v)
		if !V2Enabled() {
			t.Errorf("%q: want true", v)
		}
	}
}

func TestMaybeEmitUpdateV2_Disabled_NoOp(t *testing.T) {
	t.Setenv(EnvAuditV2Enabled, "false")
	db, mock, tx := newSQLMockTx(t)
	defer db.Close()
	e := NewDBEmitter()
	err := MaybeEmitUpdateV2(
		context.Background(),
		&stubTxExtractor{tx: tx},
		e,
		"Account", "a1", "u1",
		[]FieldDiff{{FieldName: "Name", OldValue: "X", NewValue: "Y"}},
	)
	if err != nil {
		t.Fatalf("disabled path: want nil, got %v", err)
	}
	// no SQL should be executed
	mock.ExpectRollback()
	_ = tx.Rollback()
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unexpected SQL: %v", err)
	}
}

func TestMaybeEmitUpdateV2_EmptyDiffs_NoOp(t *testing.T) {
	t.Setenv(EnvAuditV2Enabled, "true")
	db, mock, tx := newSQLMockTx(t)
	defer db.Close()
	err := MaybeEmitUpdateV2(
		context.Background(),
		&stubTxExtractor{tx: tx},
		NewDBEmitter(),
		"Account", "a1", "u1",
		nil,
	)
	if err != nil {
		t.Fatalf("empty diffs: want nil, got %v", err)
	}
	mock.ExpectRollback()
	_ = tx.Rollback()
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unexpected SQL: %v", err)
	}
}

func TestMaybeEmitUpdateV2_NoTx_NoOp(t *testing.T) {
	t.Setenv(EnvAuditV2Enabled, "true")
	err := MaybeEmitUpdateV2(
		context.Background(),
		&stubTxExtractor{tx: nil},
		NewDBEmitter(),
		"Account", "a1", "u1",
		[]FieldDiff{{FieldName: "Name", OldValue: "X", NewValue: "Y"}},
	)
	if err != nil {
		t.Fatalf("no-tx path: want nil, got %v", err)
	}
}

// TestMaybeEmitUpdateV2_Happy_EmitsOutboxAndHead is the integration test that
// proves the wiring reaches DBEmitter.EmitTx and writes both rows.
func TestMaybeEmitUpdateV2_Happy_EmitsOutboxAndHead(t *testing.T) {
	t.Setenv(EnvAuditV2Enabled, "true")
	db, mock, tx := newSQLMockTx(t)
	defer db.Close()
	// DBEmitter.EmitTx: first INSERT outbox, then INSERT head.
	mock.ExpectExec(`INSERT INTO _System_AuditOutbox`).
		WillReturnResult(sqlmock.NewResult(42, 1))
	mock.ExpectExec(`INSERT INTO _System_AuditLog`).
		WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectCommit()

	ctx := WithEntrypoint(context.Background(), EntrypointRestAPI)
	err := MaybeEmitUpdateV2(
		ctx,
		&stubTxExtractor{tx: tx},
		NewDBEmitter(),
		"Account", "a1", "u1",
		[]FieldDiff{{FieldName: "Name", OldValue: "X", NewValue: "Y"}},
	)
	if err != nil {
		t.Fatalf("happy path: %v", err)
	}
	if err := tx.Commit(); err != nil {
		t.Fatalf("commit: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("expectations: %v", err)
	}
}

// TestRequestIDFromContext_ReadsTypedKey asserts that the audit package
// reads X-Request-ID from the standard constants.ContextKeyRequestID key
// that middleware.RequestID() writes. Without this, _System_AuditLog.
// RequestId is always NULL even when RequestCapture has it — which breaks
// cmd/replay's capture↔audit join (§14.5).
func TestRequestIDFromContext_ReadsTypedKey(t *testing.T) {
	ctx := context.WithValue(context.Background(), constants.ContextKeyRequestID, "req-verify-xyz")
	if got := requestIDFromContext(ctx); got != "req-verify-xyz" {
		t.Fatalf("requestIDFromContext = %q, want req-verify-xyz", got)
	}
}

// TestRequestIDFromContext_MissingReturnsEmpty guards the best-effort
// contract: ctx without the key yields empty, not a crash.
func TestRequestIDFromContext_MissingReturnsEmpty(t *testing.T) {
	if got := requestIDFromContext(context.Background()); got != "" {
		t.Fatalf("missing key: got %q, want empty", got)
	}
}

// TestTraceIDFromContext_ExtractsFromTraceparent: middleware.Tracing()
// stores the full W3C traceparent ("00-<traceid>-<spanid>-01"). The audit
// emitter wants just the trace-id segment, matching how request_logger
// already extracts it.
func TestTraceIDFromContext_ExtractsFromTraceparent(t *testing.T) {
	ctx := context.WithValue(
		context.Background(),
		constants.ContextKeyTraceparent,
		"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
	)
	want := "4bf92f3577b34da6a3ce929d0e0e4736"
	if got := traceIDFromContext(ctx); got != want {
		t.Fatalf("traceIDFromContext = %q, want %q", got, want)
	}
}

func TestTraceIDFromContext_MalformedReturnsEmpty(t *testing.T) {
	ctx := context.WithValue(context.Background(), constants.ContextKeyTraceparent, "not-traceparent")
	if got := traceIDFromContext(ctx); got != "" {
		t.Fatalf("malformed: got %q, want empty", got)
	}
}

// TestMaybeEmitUpdateV2_PropagatesRequestIDFromContext asserts that the
// RequestId from ctx (set by middleware.RequestID()) reaches the emitter's
// INSERT. Evidence: `_System_AuditLog.RequestId` column receives the value.
func TestMaybeEmitUpdateV2_PropagatesRequestIDFromContext(t *testing.T) {
	t.Setenv(EnvAuditV2Enabled, "true")
	db, mock, tx := newSQLMockTx(t)
	defer db.Close()
	mock.ExpectExec(`INSERT INTO _System_AuditOutbox`).
		WillReturnResult(sqlmock.NewResult(42, 1))
	// Column order (from emitter_db.go head INSERT):
	//  1 Id, 2 ObjectApiName, 3 RecordId, 4 FieldName,
	//  5 ChangedById, 6 ChangedAt, 7 CreatedDate, 8 LastModifiedDate,
	//  9 Action, 10 ChangesJSON, 11 Entrypoint, 12 EntrypointDetail,
	// 13 RequestId, 14 TraceId,
	// 15 SessionId, 16 ParentRequestId, 17 ActualUserId,
	// 18 ClientIp, 19 UserAgent, 20 Outcome, 21 ErrorCode, 22 OutboxRowId
	mock.ExpectExec(`INSERT INTO _System_AuditLog`).
		WithArgs(
			sqlmock.AnyArg(),        // 1 Id
			"Account",                // 2 ObjectApiName
			"a1",                     // 3 RecordId
			sqlmock.AnyArg(),        // 4 FieldName sentinel
			"u1",                     // 5 ChangedById
			sqlmock.AnyArg(),        // 6 ChangedAt
			sqlmock.AnyArg(),        // 7 CreatedDate
			sqlmock.AnyArg(),        // 8 LastModifiedDate
			sqlmock.AnyArg(),        // 9 Action
			sqlmock.AnyArg(),        // 10 ChangesJSON
			sqlmock.AnyArg(),        // 11 Entrypoint
			sqlmock.AnyArg(),        // 12 EntrypointDetail
			"req-verify-xyz",        // 13 RequestId (the assertion)
			"4bf92f3577b34da6a3ce929d0e0e4736", // 14 TraceId (the assertion)
			sqlmock.AnyArg(),        // 15 SessionId
			sqlmock.AnyArg(),        // 16 ParentRequestId
			sqlmock.AnyArg(),        // 17 ActualUserId
			sqlmock.AnyArg(),        // 18 ClientIp
			sqlmock.AnyArg(),        // 19 UserAgent
			sqlmock.AnyArg(),        // 20 Outcome
			sqlmock.AnyArg(),        // 21 ErrorCode
			sqlmock.AnyArg(),        // 22 OutboxRowId
		).
		WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectCommit()

	ctx := WithEntrypoint(context.Background(), EntrypointRestAPI)
	ctx = context.WithValue(ctx, constants.ContextKeyRequestID, "req-verify-xyz")
	ctx = context.WithValue(ctx, constants.ContextKeyTraceparent,
		"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
	err := MaybeEmitUpdateV2(
		ctx,
		&stubTxExtractor{tx: tx},
		NewDBEmitter(),
		"Account", "a1", "u1",
		[]FieldDiff{{FieldName: "Name", OldValue: "X", NewValue: "Y"}},
	)
	if err != nil {
		t.Fatalf("emit: %v", err)
	}
	if err := tx.Commit(); err != nil {
		t.Fatalf("commit: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("expectations: %v", err)
	}
}

// TestMaybeEmitCreateV2_PropagatesRequestIDFromContext mirrors the UPDATE
// assertion for the CREATE hook (C11).
func TestMaybeEmitCreateV2_PropagatesRequestIDFromContext(t *testing.T) {
	t.Setenv(EnvAuditV2Enabled, "true")
	db, mock, tx := newSQLMockTx(t)
	defer db.Close()
	mock.ExpectExec(`INSERT INTO _System_AuditOutbox`).
		WillReturnResult(sqlmock.NewResult(43, 1))
	mock.ExpectExec(`INSERT INTO _System_AuditLog`).
		WithArgs(
			sqlmock.AnyArg(), "Account", "a1", sqlmock.AnyArg(),
			"u1", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
			sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
			"req-create-abc",
			"4bf92f3577b34da6a3ce929d0e0e4736",
			sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
			sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
			sqlmock.AnyArg(), sqlmock.AnyArg(),
		).
		WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectCommit()

	ctx := WithEntrypoint(context.Background(), EntrypointRestAPI)
	ctx = context.WithValue(ctx, constants.ContextKeyRequestID, "req-create-abc")
	ctx = context.WithValue(ctx, constants.ContextKeyTraceparent,
		"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
	err := MaybeEmitCreateV2(
		ctx,
		&stubTxExtractor{tx: tx},
		NewDBEmitter(),
		"Account", "a1", "u1",
	)
	if err != nil {
		t.Fatalf("emit: %v", err)
	}
	if err := tx.Commit(); err != nil {
		t.Fatalf("commit: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("expectations: %v", err)
	}
}

// TestMaybeEmitUpdateV2_PopulatesChangesJSON verifies multi-field UPDATEs
// carry the changed-field set on the head row (audit-log-impl gap fix).
// Before this fix, ChangesJSON stayed NULL and forensic replay had to
// re-join the outbox row to learn which fields changed.
func TestMaybeEmitUpdateV2_PopulatesChangesJSON(t *testing.T) {
	t.Setenv(EnvAuditV2Enabled, "true")
	db, mock, tx := newSQLMockTx(t)
	defer db.Close()

	mock.ExpectExec(`INSERT INTO _System_AuditOutbox`).
		WillReturnResult(sqlmock.NewResult(99, 1))
	// Capture the ChangesJSON argument via a regex-flavoured matcher:
	// sqlmock.AnyArg allows us to pull the recorded args back out for
	// inspection. Here we use WithArgs positionally and verify via a
	// dedicated ValueChecker on position 10 (ChangesJSON).
	mock.ExpectExec(`INSERT INTO _System_AuditLog`).
		WithArgs(
			sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
			sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
			sqlmock.AnyArg(),
			changesJSONContains([]string{"Name", "Description", "Industry"}),
			sqlmock.AnyArg(), sqlmock.AnyArg(),
			sqlmock.AnyArg(), sqlmock.AnyArg(),
			sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
			sqlmock.AnyArg(), sqlmock.AnyArg(),
			sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
		).
		WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectCommit()

	ctx := WithEntrypoint(context.Background(), EntrypointRestAPI)
	err := MaybeEmitUpdateV2(
		ctx,
		&stubTxExtractor{tx: tx},
		NewDBEmitter(),
		"Account", "a1", "u1",
		[]FieldDiff{
			{FieldName: "Name", OldValue: "A", NewValue: "B"},
			{FieldName: "Description", OldValue: "", NewValue: "hi"},
			{FieldName: "Industry", OldValue: "Tech", NewValue: "Finance"},
		},
	)
	if err != nil {
		t.Fatalf("emit: %v", err)
	}
	if err := tx.Commit(); err != nil {
		t.Fatalf("commit: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("expectations: %v", err)
	}
}

// TestMaybeEmitDeleteV2_WritesHeadRow verifies DELETE emits a head row
// with Action=DELETE. Before this fix, DELETE was silent in the audit
// trail — PersistenceService.Delete ran without any V2 hook.
func TestMaybeEmitDeleteV2_WritesHeadRow(t *testing.T) {
	t.Setenv(EnvAuditV2Enabled, "true")
	db, mock, tx := newSQLMockTx(t)
	defer db.Close()

	mock.ExpectExec(`INSERT INTO _System_AuditOutbox`).
		WillReturnResult(sqlmock.NewResult(101, 1))
	mock.ExpectExec(`INSERT INTO _System_AuditLog`).
		WithArgs(
			sqlmock.AnyArg(), "Account", "a1", sqlmock.AnyArg(),
			"u1", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
			"DELETE", // position 9 — Action
			sqlmock.AnyArg(), // 10 ChangesJSON
			sqlmock.AnyArg(), sqlmock.AnyArg(),
			sqlmock.AnyArg(), sqlmock.AnyArg(),
			sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
			sqlmock.AnyArg(), sqlmock.AnyArg(),
			sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
		).
		WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectCommit()

	ctx := WithEntrypoint(context.Background(), EntrypointRestAPI)
	err := MaybeEmitDeleteV2(
		ctx,
		&stubTxExtractor{tx: tx},
		NewDBEmitter(),
		"Account", "a1", "u1",
	)
	if err != nil {
		t.Fatalf("emit: %v", err)
	}
	if err := tx.Commit(); err != nil {
		t.Fatalf("commit: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("expectations: %v", err)
	}
}

// changesJSONContains is a sqlmock argument matcher that checks the
// ChangesJSON-shaped value (string / []byte) contains all expected field
// names via substring match.
type changesJSONMatcher struct{ want []string }

func changesJSONContains(want []string) changesJSONMatcher { return changesJSONMatcher{want: want} }

func (m changesJSONMatcher) Match(v driver.Value) bool {
	var s string
	switch t := v.(type) {
	case string:
		s = t
	case []byte:
		s = string(t)
	default:
		return false
	}
	for _, w := range m.want {
		if !strings.Contains(s, w) {
			return false
		}
	}
	return true
}
