package admission

import (
	"context"
	"database/sql/driver"
	"errors"
	"testing"
	"time"

	sqlmock "github.com/DATA-DOG/go-sqlmock"
)

func TestSQLGate_ChannelSpecificPauseWins(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery(`SELECT Mode, COALESCE\(RetryAfterSeconds, 0\), COALESCE\(Reason, ''\).*FROM _System_ConnectorIngressControl`).
		WithArgs(
			"hubspot-crm-connector",
			"install-1",
			"webhook:default",
			"webhook",
			"*",
			sqlmock.AnyArg(),
			"install-1",
			"webhook:default",
			"webhook",
			"*",
		).
		WillReturnRows(sqlmock.NewRows([]string{"Mode", "RetryAfterSeconds", "Reason", "FenceEpoch"}).AddRow("quiescing", 45, "deploy", int64(7)))

	gate := NewSQLGate(db)
	gate.now = func() time.Time { return time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC) }

	decision, err := gate.Decide(context.Background(), " HubSpot-CRM-Connector ", "install-1", " Webhook:Default ")
	if err != nil {
		t.Fatalf("Decide returned error: %v", err)
	}
	if decision.Allow {
		t.Fatal("Allow = true, want false")
	}
	if decision.Mode != ModeQuiescing || decision.RetryAfterSeconds != 45 || decision.Reason != "deploy" {
		t.Fatalf("decision = %+v, want quiescing/45/deploy", decision)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet expectations: %v", err)
	}
}

func TestSQLGate_DecideFailsClosedOnPreEpochTable(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery(`COALESCE\(FenceEpoch, 0\)`).
		WillReturnError(errors.New("Error 1054 (42S22): Unknown column 'FenceEpoch' in 'field list'"))

	decision, err := NewSQLGate(db).Decide(context.Background(), "hubspot-crm-connector", "install-1", "webhook")
	if !errors.Is(err, ErrIngressSchemaUnavailable) {
		t.Fatalf("Decide error = %v, want ErrIngressSchemaUnavailable", err)
	}
	var unavailable *SchemaUnavailableError
	if !errors.As(err, &unavailable) {
		t.Fatalf("Decide error = %T, want *SchemaUnavailableError", err)
	}
	if decision.Allow || decision.Mode != ModeUnavailable {
		t.Fatalf("decision = %+v, want unavailable deny", decision)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet expectations: %v", err)
	}
}

func TestSQLGate_NoRowAllows(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery(`FROM _System_ConnectorIngressControl`).
		WillReturnRows(sqlmock.NewRows([]string{"Mode", "RetryAfterSeconds", "Reason"}))

	decision, err := NewSQLGate(db).Decide(context.Background(), "hubspot-crm-connector", "install-1", "webhook")
	if err != nil {
		t.Fatalf("Decide returned error: %v", err)
	}
	if !decision.Allow || decision.Mode != ModeActive {
		t.Fatalf("decision = %+v, want active allow", decision)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet expectations: %v", err)
	}
}

// Decision is exported by the module. Keep this unkeyed literal so adding a
// field becomes a compile-time compatibility review instead of silently
// breaking a pinned consumer that already uses the historical field layout.
func TestDecisionUnkeyedLiteralCompatibility(t *testing.T) {
	decision := Decision{true, ModeActive, 120, "compatibility"}
	if !decision.Allow || decision.Mode != ModeActive || decision.RetryAfterSeconds != 120 || decision.Reason != "compatibility" {
		t.Fatalf("Decision = %+v, want historical four-field layout", decision)
	}
}

func TestSQLGate_MissingTableReturnsTypedSchemaUnavailable(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery(`FROM _System_ConnectorIngressControl`).
		WillReturnError(errors.New("Error 1146 (42S02): Table 'nexuscrm._System_ConnectorIngressControl' doesn't exist"))

	decision, err := NewSQLGate(db).Decide(context.Background(), "hubspot-crm-connector", "install-1", "webhook")
	if !errors.Is(err, ErrIngressSchemaUnavailable) {
		t.Fatalf("Decide error = %v, want ErrIngressSchemaUnavailable", err)
	}
	var unavailable *SchemaUnavailableError
	if !errors.As(err, &unavailable) {
		t.Fatalf("Decide error = %T, want *SchemaUnavailableError", err)
	}
	if decision.Allow || decision.Mode != ModeUnavailable {
		t.Fatalf("decision = %+v, want unavailable deny", decision)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet expectations: %v", err)
	}
}

func TestSQLGate_RealSQLErrorFailsClosed(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery(`FROM _System_ConnectorIngressControl`).
		WillReturnError(errors.New("dial tcp: connection reset"))

	_, err = NewSQLGate(db).Decide(context.Background(), "hubspot-crm-connector", "install-1", "webhook")
	if err == nil {
		t.Fatal("Decide error = nil, want fail-closed error")
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet expectations: %v", err)
	}
}

func TestSQLGate_RequireActiveTxLocksStableNamespaceFence(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	const namespace = "hubspot-crm-connector"
	mock.ExpectBegin()
	mock.ExpectExec(`INSERT INTO _System_ConnectorIngressControl`).
		WithArgs(NamespaceFenceID(namespace), namespace, ModeActive).
		WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectQuery(`SELECT PackInstallId, Mode, COALESCE\(RetryAfterSeconds, 0\), COALESCE\(Reason, ''\), FenceEpoch, ExpiresAt.*FOR UPDATE`).
		WithArgs(namespace).
		WillReturnRows(sqlmock.NewRows([]string{"PackInstallId", "Mode", "RetryAfterSeconds", "Reason", "FenceEpoch", "ExpiresAt"}).
			AddRow("", ModeActive, 120, "", int64(7), nil))
	mock.ExpectCommit()

	tx, err := db.BeginTx(context.Background(), nil)
	if err != nil {
		t.Fatalf("BeginTx: %v", err)
	}
	fence, err := NewSQLGate(db).RequireActiveTx(context.Background(), tx, namespace)
	if err != nil {
		t.Fatalf("RequireActiveTx: %v", err)
	}
	if fence.Namespace != namespace || fence.Mode != ModeActive || fence.Epoch != 7 {
		t.Fatalf("fence = %+v, want active namespace fence at epoch 7", fence)
	}
	if err := tx.Commit(); err != nil {
		t.Fatalf("Commit: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet expectations: %v", err)
	}
}

func TestSQLGate_RequireActiveTxRestoresSoftDeletedNamespaceFence(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	const namespace = "hubspot-crm-connector"
	mock.ExpectBegin()
	mock.ExpectExec(`(?s)INSERT INTO _System_ConnectorIngressControl.*ON DUPLICATE KEY UPDATE.*Mode = IF\(IsDeleted <> 0, 'active', Mode\).*RetryAfterSeconds = IF\(IsDeleted <> 0, 120, RetryAfterSeconds\).*Reason = IF\(IsDeleted <> 0, NULL, Reason\).*ExpiresAt = IF\(IsDeleted <> 0, NULL, ExpiresAt\).*IsDeleted = 0`).
		WithArgs(NamespaceFenceID(namespace), namespace, ModeActive).
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectQuery(`SELECT PackInstallId, Mode, COALESCE\(RetryAfterSeconds, 0\), COALESCE\(Reason, ''\), FenceEpoch, ExpiresAt.*FOR UPDATE`).
		WithArgs(namespace).
		WillReturnRows(sqlmock.NewRows([]string{"PackInstallId", "Mode", "RetryAfterSeconds", "Reason", "FenceEpoch", "ExpiresAt"}).
			AddRow("", ModeActive, 120, "", int64(41), nil))
	mock.ExpectCommit()

	tx, err := db.BeginTx(context.Background(), nil)
	if err != nil {
		t.Fatalf("BeginTx: %v", err)
	}
	fence, err := NewSQLGate(db).RequireActiveTx(context.Background(), tx, namespace)
	if err != nil {
		t.Fatalf("RequireActiveTx: %v", err)
	}
	if got := fence; got.Mode != ModeActive || got.Epoch != 41 {
		t.Fatalf("restored fence = %+v, want active fence preserving epoch 41", got)
	}
	if err := tx.Commit(); err != nil {
		t.Fatalf("Commit: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet expectations: %v", err)
	}
}

func TestNoopGateRemainsCompatible(t *testing.T) {
	decision, err := NewNoopGate().Decide(context.Background(), "hubspot-crm-connector", "pack", "webhook")
	if err != nil {
		t.Fatalf("NoopGate.Decide: %v", err)
	}
	if !decision.Allow || decision.Mode != ModeActive {
		t.Fatalf("NoopGate decision = %+v, want active allow", decision)
	}
}

func TestSQLGate_RequireActiveTxReturnsTypedQuiescedFence(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	const namespace = "hubspot-crm-connector"
	mock.ExpectBegin()
	mock.ExpectExec(`INSERT INTO _System_ConnectorIngressControl`).
		WithArgs(NamespaceFenceID(namespace), namespace, ModeActive).
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectQuery(`SELECT PackInstallId, Mode, COALESCE\(RetryAfterSeconds, 0\), COALESCE\(Reason, ''\), FenceEpoch, ExpiresAt.*FOR UPDATE`).
		WithArgs(namespace).
		WillReturnRows(sqlmock.NewRows([]string{"PackInstallId", "Mode", "RetryAfterSeconds", "Reason", "FenceEpoch", "ExpiresAt"}).
			AddRow("", ModeQuiescing, 45, "release", int64(41), nil))
	mock.ExpectRollback()

	tx, err := db.BeginTx(context.Background(), nil)
	if err != nil {
		t.Fatalf("BeginTx: %v", err)
	}
	_, err = NewSQLGate(db).RequireActiveTx(context.Background(), tx, namespace)
	if !errors.Is(err, ErrIngressQuiesced) {
		t.Fatalf("RequireActiveTx error = %v, want ErrIngressQuiesced", err)
	}
	var quiesced *QuiescedError
	if !errors.As(err, &quiesced) {
		t.Fatalf("RequireActiveTx error = %T, want *QuiescedError", err)
	}
	if got := quiesced.Fence; got.Namespace != namespace || got.Mode != ModeQuiescing || got.RetryAfterSeconds != 45 || got.Epoch != 41 {
		t.Fatalf("quiesced fence = %+v, want namespace/quiescing/retry=45/epoch=41", got)
	}
	if err := tx.Rollback(); err != nil {
		t.Fatalf("Rollback: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet expectations: %v", err)
	}
}

func TestSQLGate_RequireActiveTxFailsClosedOnPreEpochTable(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectBegin()
	mock.ExpectExec(`INSERT INTO _System_ConnectorIngressControl`).
		WillReturnError(errors.New("Error 1054 (42S22): Unknown column 'FenceEpoch' in 'field list'"))
	mock.ExpectRollback()

	tx, err := db.BeginTx(context.Background(), nil)
	if err != nil {
		t.Fatalf("BeginTx: %v", err)
	}
	_, err = NewSQLGate(db).RequireActiveTx(context.Background(), tx, "hubspot-crm-connector")
	if !errors.Is(err, ErrIngressSchemaUnavailable) {
		t.Fatalf("RequireActiveTx error = %v, want ErrIngressSchemaUnavailable", err)
	}
	if err := tx.Rollback(); err != nil {
		t.Fatalf("Rollback: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet expectations: %v", err)
	}
}

func TestSQLGate_RequireActiveTxRejectsAmbiguousLogicalGlobalFence(t *testing.T) {
	tests := []struct {
		name string
		rows *sqlmock.Rows
	}{
		{
			name: "legacy null scope",
			rows: sqlmock.NewRows([]string{"PackInstallId", "Mode", "RetryAfterSeconds", "Reason", "FenceEpoch", "ExpiresAt"}).
				AddRow("", ModeActive, 120, "", int64(7), nil).
				AddRow(nil, ModeQuiescing, 120, "legacy", int64(6), nil),
		},
		{
			name: "duplicate canonical rows",
			rows: sqlmock.NewRows([]string{"PackInstallId", "Mode", "RetryAfterSeconds", "Reason", "FenceEpoch", "ExpiresAt"}).
				AddRow("", ModeActive, 120, "", int64(7), nil).
				AddRow("", ModeQuiescing, 120, "duplicate", int64(6), nil),
		},
		{
			name: "canonical row without epoch",
			rows: sqlmock.NewRows([]string{"PackInstallId", "Mode", "RetryAfterSeconds", "Reason", "FenceEpoch", "ExpiresAt"}).
				AddRow("", ModeActive, 120, "", nil, nil),
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			db, mock, err := sqlmock.New()
			if err != nil {
				t.Fatalf("sqlmock.New: %v", err)
			}
			defer db.Close()

			mock.ExpectBegin()
			mock.ExpectExec(`INSERT INTO _System_ConnectorIngressControl`).
				WithArgs(NamespaceFenceID("hubspot-crm-connector"), "hubspot-crm-connector", ModeActive).
				WillReturnResult(sqlmock.NewResult(0, 0))
			mock.ExpectQuery(`SELECT PackInstallId, Mode, COALESCE\(RetryAfterSeconds, 0\), COALESCE\(Reason, ''\), FenceEpoch, ExpiresAt.*PackInstallId IS NULL.*FOR UPDATE`).
				WithArgs("hubspot-crm-connector").
				WillReturnRows(test.rows)
			mock.ExpectRollback()

			tx, err := db.BeginTx(context.Background(), nil)
			if err != nil {
				t.Fatalf("BeginTx: %v", err)
			}
			_, err = NewSQLGate(db).RequireActiveTx(context.Background(), tx, "hubspot-crm-connector")
			if !errors.Is(err, ErrIngressSchemaUnavailable) {
				t.Fatalf("RequireActiveTx error = %v, want ErrIngressSchemaUnavailable", err)
			}
			if err := tx.Rollback(); err != nil {
				t.Fatalf("Rollback: %v", err)
			}
			if err := mock.ExpectationsWereMet(); err != nil {
				t.Fatalf("unmet expectations: %v", err)
			}
		})
	}
}

func TestNamespaceFenceIDCanonicalizesNamespace(t *testing.T) {
	if got, want := NamespaceFenceID(" HubSpot-CRM-Connector "), NamespaceFenceID("hubspot-crm-connector"); got != want {
		t.Fatalf("NamespaceFenceID did not canonicalize namespace: %q != %q", got, want)
	}
}

func TestSQLGate_TransitionNamespaceFenceArmResumeArmIsMonotonic(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	const namespace = "hubspot-crm-connector"
	expectTransition := func(mode string, epoch int64, target string, wantUpdate bool, updateArgs ...driver.Value) {
		t.Helper()
		mock.ExpectExec("SET SESSION tidb_txn_mode = 'pessimistic'").WillReturnResult(sqlmock.NewResult(0, 0))
		mock.ExpectBegin()
		mock.ExpectExec(`INSERT INTO _System_ConnectorIngressControl`).
			WithArgs(NamespaceFenceID(namespace), namespace, ModeActive).
			WillReturnResult(sqlmock.NewResult(0, 0))
		mock.ExpectQuery(`SELECT PackInstallId, Mode, COALESCE\(RetryAfterSeconds, 0\), COALESCE\(Reason, ''\), FenceEpoch, ExpiresAt.*FOR UPDATE`).
			WithArgs(namespace).
			WillReturnRows(sqlmock.NewRows([]string{"PackInstallId", "Mode", "RetryAfterSeconds", "Reason", "FenceEpoch", "ExpiresAt"}).
				AddRow("", mode, 120, "", epoch, nil))
		if wantUpdate {
			mock.ExpectExec(`UPDATE _System_ConnectorIngressControl`).WithArgs(updateArgs...).WillReturnResult(sqlmock.NewResult(0, 1))
		}
		mock.ExpectCommit()
		mock.ExpectExec("SET SESSION tidb_txn_mode = DEFAULT").WillReturnResult(sqlmock.NewResult(0, 0))
	}

	expectTransition(ModeActive, 0, ModeQuiescing, true, ModeQuiescing, 120, "release", namespace)
	arm1, err := NewSQLGate(db).TransitionNamespaceFence(context.Background(), NamespaceFenceTransition{
		Namespace: namespace, TargetMode: ModeQuiescing, Reason: "release",
	})
	if err != nil {
		t.Fatalf("first arm: %v", err)
	}
	if arm1.Fence.Epoch != 1 || arm1.Receipt == nil || arm1.Receipt.Epoch != 1 {
		t.Fatalf("first arm result = %+v, want committed receipt at epoch 1", arm1)
	}

	expectTransition(ModeQuiescing, 1, ModeActive, true, ModeActive, namespace)
	resume, err := NewSQLGate(db).TransitionNamespaceFence(context.Background(), NamespaceFenceTransition{Namespace: namespace, TargetMode: ModeActive})
	if err != nil {
		t.Fatalf("resume: %v", err)
	}
	if resume.Fence.Epoch != 1 || resume.Receipt != nil {
		t.Fatalf("resume result = %+v, want epoch preserved and no receipt", resume)
	}

	expectTransition(ModeActive, 1, ModeQuiescing, true, ModeQuiescing, 120, "release-2", namespace)
	arm2, err := NewSQLGate(db).TransitionNamespaceFence(context.Background(), NamespaceFenceTransition{
		Namespace: namespace, TargetMode: ModeQuiescing, Reason: "release-2",
	})
	if err != nil {
		t.Fatalf("second arm: %v", err)
	}
	if arm2.Fence.Epoch != 2 || arm2.Receipt == nil || arm2.Receipt.Epoch != 2 {
		t.Fatalf("second arm result = %+v, want committed receipt at epoch 2", arm2)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet expectations: %v", err)
	}
}

func TestFenceCommittedReceiptRejectsPreEpochFence(t *testing.T) {
	_, err := (Fence{Namespace: "hubspot-crm-connector", Mode: ModeQuiescing, Epoch: 0, Capability: CapabilityAvailable}).CommittedReceipt()
	if err == nil {
		t.Fatal("CommittedReceipt accepted epoch 0")
	}
}
