package catalogverificationtx

import (
	"context"
	"database/sql"
	"errors"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/pingcap-inc/nexuscrm-contract/pkg/appauthority"
	layoutbundlekeyv1 "github.com/pingcap-inc/nexuscrm-contract/pkg/layoutbundlekeyv1"
	layoutbundleslotv1 "github.com/pingcap-inc/nexuscrm-contract/pkg/layoutbundleslotv1"
)

const (
	testAppID             = "io.example.alpha"
	testAppInstallationID = "018f0000-0000-7000-9000-000000000011"
	testAppOrgID          = "org-1"
)

func TestVerifyAppOwnerLocksExactActiveTupleAndHistoricalReleaseSlot(
	t *testing.T,
) {
	db, mock, token := newVerificationToken(t)
	defer db.Close()
	request := validAppOwnerRequest()
	firstTarget := appOwnerDigest("1")
	secondTarget := appOwnerDigest("2")
	firstRevision := appOwnerDigest("a")
	secondRevision := appOwnerDigest("b")
	declarationID := appauthority.ComponentID(
		testAppID,
		layoutbundleslotv1.ComponentKind,
		string(request.LocalComponentKey),
	)

	expectExactActiveInstallation(mock, request)
	expectExactRelease(mock, request)
	expectExactReleaseSlot(mock, request, 1)
	expectSlotReservation(
		mock,
		request,
		declarationID,
		2,
		secondRevision,
	)
	expectSlotVersions(mock, request, []slotVersionRow{
		{version: 1, revision: firstRevision, target: firstTarget},
		{
			version:  2,
			revision: secondRevision,
			target:   secondTarget,
			previous: &firstTarget,
		},
	})

	required := requiredBundleKeySetForTest(appBundleKeyForTest(request))
	lease, err := NewLease(token, required)
	if err != nil {
		t.Fatal(err)
	}
	facts := appSourceFactsForTest(t, request, required)
	claim, err := lease.BeginAppClaim(
		0,
		facts,
		request,
	)
	if err != nil {
		t.Fatal(err)
	}
	fact, err := VerifyAppOwnerClaim(context.Background(), db, claim)
	if err != nil {
		t.Fatal(err)
	}
	if fact.AppInstallationID != request.AppInstallationID ||
		fact.AppReleaseDigest != request.AppReleaseDigest ||
		fact.DeclarationComponentID != declarationID ||
		fact.DefinitionVersion != 1 ||
		fact.LayoutBundleSlotRevisionDigest != firstRevision ||
		fact.CatalogRevision != 7 ||
		fact.ActivationEpoch != 3 {
		t.Fatalf("App owner fact = %#v", fact)
	}
	if _, err := VerifyAppOwnerClaim(
		context.Background(),
		db,
		claim,
	); !errors.Is(err, ErrClosed) {
		t.Fatalf("App claim verification replay = %v, want ErrClosed", err)
	}
	if err := CompleteAppClaim(claim, receiptForAppFacts(facts)); err != nil {
		t.Fatal(err)
	}
	if !claim.Active() {
		t.Fatal("completed App claim is not active")
	}
	if err := CompleteAppClaim(
		claim,
		receiptForAppFacts(facts),
	); !errors.Is(err, ErrClosed) {
		t.Fatalf("second App claim completion = %v, want ErrClosed", err)
	}
	mock.ExpectRollback()
	if err := lease.Close(); err != nil {
		t.Fatal(err)
	}
	if claim.Active() {
		t.Fatal("App claim remained active after source lease close")
	}
	assertVerificationExpectations(t, mock)
}

func TestVerifyAppOwnerRejectsSelectedInstallationIdentityMismatch(t *testing.T) {
	db, mock, token := newVerificationToken(t)
	defer db.Close()
	request := validAppOwnerRequest()
	rows := sqlmock.NewRows(appInstallationColumns()).
		AddRow(
			request.AppInstallationID,
			"org-foreign",
			request.AppID,
			"ACTIVE",
			request.AppReleaseDigest,
			nil,
			[]byte("7"),
			[]byte("3"),
			nil,
			appLiveKey(request.OrgID, request.AppID),
		)
	mock.ExpectQuery(`(?s)FROM _System_CatalogAppInstallation.*WHERE OrgId = \? AND AppId = \? AND State <> 'REMOVED'.*LIMIT 2 FOR UPDATE`).
		WithArgs(request.OrgID, request.AppID).
		WillReturnRows(rows)

	if _, err := VerifyAppOwner(
		context.Background(),
		token,
		db,
		request,
	); !errors.Is(err, ErrAppOwnerConflict) {
		t.Fatalf("selected installation identity mismatch = %v", err)
	}
	mock.ExpectRollback()
	if err := token.Close(); err != nil {
		t.Fatal(err)
	}
	assertVerificationExpectations(t, mock)
}

func TestVerifyAppOwnerRejectsAmbiguousNonRemovedInstallations(t *testing.T) {
	db, mock, token := newVerificationToken(t)
	defer db.Close()
	request := validAppOwnerRequest()
	rows := sqlmock.NewRows(appInstallationColumns()).
		AddRow(
			request.AppInstallationID,
			request.OrgID,
			request.AppID,
			"ACTIVE",
			request.AppReleaseDigest,
			nil,
			[]byte("7"),
			[]byte("3"),
			nil,
			appLiveKey(request.OrgID, request.AppID),
		).
		AddRow(
			"018f0000-0000-7000-9000-000000000012",
			request.OrgID,
			request.AppID,
			"FAILED",
			nil,
			nil,
			[]byte("6"),
			[]byte("1"),
			nil,
			nil,
		)
	mock.ExpectQuery(`(?s)FROM _System_CatalogAppInstallation.*WHERE OrgId = \? AND AppId = \? AND State <> 'REMOVED'.*LIMIT 2 FOR UPDATE`).
		WithArgs(request.OrgID, request.AppID).
		WillReturnRows(rows)

	if _, err := VerifyAppOwner(
		context.Background(),
		token,
		db,
		request,
	); !errors.Is(err, ErrAppOwnerCorrupt) {
		t.Fatalf("ambiguous App installations = %v, want ErrAppOwnerCorrupt", err)
	}
	mock.ExpectRollback()
	if err := token.Close(); err != nil {
		t.Fatal(err)
	}
	assertVerificationExpectations(t, mock)
}

func TestVerifyAppOwnerRejectsSelectedVersionAbsentFromLineage(t *testing.T) {
	db, mock, token := newVerificationToken(t)
	defer db.Close()
	request := validAppOwnerRequest()
	target := appOwnerDigest("1")
	revision := appOwnerDigest("a")
	declarationID := appauthority.ComponentID(
		testAppID,
		layoutbundleslotv1.ComponentKind,
		string(request.LocalComponentKey),
	)
	expectExactActiveInstallation(mock, request)
	expectExactRelease(mock, request)
	expectExactReleaseSlot(mock, request, 2)
	expectSlotReservation(mock, request, declarationID, 1, revision)
	expectSlotVersions(mock, request, []slotVersionRow{
		{version: 1, revision: revision, target: target},
	})

	if _, err := VerifyAppOwner(
		context.Background(),
		token,
		db,
		request,
	); !errors.Is(err, ErrAppOwnerCorrupt) {
		t.Fatalf("missing selected version = %v", err)
	}
	mock.ExpectRollback()
	if err := token.Close(); err != nil {
		t.Fatal(err)
	}
	assertVerificationExpectations(t, mock)
}

func TestVerificationTokenRejectsForeignAuthorityDatabase(t *testing.T) {
	db, mock, token := newVerificationToken(t)
	defer db.Close()
	foreign, _, err := sqlmock.New()
	if err != nil {
		t.Fatal(err)
	}
	defer foreign.Close()
	if _, err := VerifyAppOwner(
		context.Background(),
		token,
		foreign,
		validAppOwnerRequest(),
	); !errors.Is(err, ErrAuthorityMismatch) {
		t.Fatalf("foreign authority database = %v", err)
	}
	mock.ExpectRollback()
	if err := token.Close(); err != nil {
		t.Fatal(err)
	}
	assertVerificationExpectations(t, mock)
}

func TestVerificationTokenCloseWaitsForInFlightVerifier(t *testing.T) {
	db, mock, token := newVerificationToken(t)
	defer db.Close()
	_, err := token.acquire(db)
	if err != nil {
		t.Fatal(err)
	}
	mock.ExpectRollback()
	closed := make(chan error, 1)
	go func() {
		closed <- token.Close()
	}()
	select {
	case err := <-closed:
		t.Fatalf("Close returned before verifier released token: %v", err)
	case <-time.After(25 * time.Millisecond):
	}
	token.release()
	select {
	case err := <-closed:
		if err != nil {
			t.Fatal(err)
		}
	case <-time.After(time.Second):
		t.Fatal("Close did not finish after verifier released token")
	}
	if token.ActiveFor(db) {
		t.Fatal("closed token remained active")
	}
	assertVerificationExpectations(t, mock)
}

type slotVersionRow struct {
	version  uint64
	revision string
	target   string
	previous *string
}

func newVerificationToken(
	t *testing.T,
) (*sql.DB, sqlmock.Sqlmock, *Token) {
	t.Helper()
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatal(err)
	}
	mock.ExpectBegin()
	tx, err := db.BeginTx(context.Background(), nil)
	if err != nil {
		db.Close()
		t.Fatal(err)
	}
	token := &Token{db: db, tx: tx, active: true}
	return db, mock, token
}

func validAppOwnerRequest() AppOwnerRequest {
	return AppOwnerRequest{
		OrgID:             testAppOrgID,
		AppID:             testAppID,
		AppInstallationID: testAppInstallationID,
		AppReleaseDigest:  appOwnerDigest("f"),
		LocalComponentKey: layoutbundlekeyv1.LocalComponentKey(
			"key256:" + strings.Repeat("1", 64),
		),
	}
}

func expectExactActiveInstallation(
	mock sqlmock.Sqlmock,
	request AppOwnerRequest,
) {
	mock.ExpectQuery(`(?s)FROM _System_CatalogAppInstallation.*WHERE OrgId = \? AND AppId = \? AND State <> 'REMOVED'.*LIMIT 2 FOR UPDATE`).
		WithArgs(request.OrgID, request.AppID).
		WillReturnRows(sqlmock.NewRows(appInstallationColumns()).AddRow(
			request.AppInstallationID,
			request.OrgID,
			request.AppID,
			"ACTIVE",
			request.AppReleaseDigest,
			nil,
			[]byte("7"),
			[]byte("3"),
			nil,
			appLiveKey(request.OrgID, request.AppID),
		))
}

func expectExactRelease(mock sqlmock.Sqlmock, request AppOwnerRequest) {
	mock.ExpectQuery(`(?s)FROM _System_CatalogAppRelease.*FOR UPDATE`).
		WithArgs(request.AppID, request.AppReleaseDigest).
		WillReturnRows(sqlmock.NewRows([]string{
			"AppId",
			"AppReleaseDigest",
		}).AddRow(request.AppID, request.AppReleaseDigest))
}

func expectExactReleaseSlot(
	mock sqlmock.Sqlmock,
	request AppOwnerRequest,
	version uint64,
) {
	mock.ExpectQuery(`(?s)FROM _System_CatalogAppReleaseLayoutBundleSlot.*FOR UPDATE`).
		WithArgs(request.AppID, request.AppReleaseDigest, request.LocalComponentKey).
		WillReturnRows(sqlmock.NewRows([]string{
			"AppId",
			"AppReleaseDigest",
			"LocalComponentKey",
			"DefinitionVersion",
		}).AddRow(
			request.AppID,
			request.AppReleaseDigest,
			request.LocalComponentKey,
			[]byte(strconv.FormatUint(version, 10)),
		))
}

func expectSlotReservation(
	mock sqlmock.Sqlmock,
	request AppOwnerRequest,
	declarationID string,
	headVersion uint64,
	headRevision string,
) {
	mock.ExpectQuery(`(?s)FROM _System_CatalogAppLayoutBundleSlot.*FOR UPDATE`).
		WithArgs(request.AppID, request.LocalComponentKey).
		WillReturnRows(sqlmock.NewRows([]string{
			"AppId",
			"LocalComponentKey",
			"DeclarationComponentId",
			"NamespaceId",
			"HeadDefinitionVersion",
			"HeadRevisionDigest",
		}).AddRow(
			request.AppID,
			request.LocalComponentKey,
			declarationID,
			request.AppID,
			[]byte(strconv.FormatUint(headVersion, 10)),
			headRevision,
		))
}

func expectSlotVersions(
	mock sqlmock.Sqlmock,
	request AppOwnerRequest,
	versions []slotVersionRow,
) {
	rows := sqlmock.NewRows([]string{
		"AppId",
		"LocalComponentKey",
		"DefinitionVersion",
		"RevisionDigest",
		"TargetComponentId",
		"PreviousTargetComponentId",
	})
	for _, version := range versions {
		var previous any
		if version.previous != nil {
			previous = *version.previous
		}
		rows.AddRow(
			request.AppID,
			request.LocalComponentKey,
			[]byte(strconv.FormatUint(version.version, 10)),
			version.revision,
			version.target,
			previous,
		)
	}
	mock.ExpectQuery(`(?s)FROM _System_CatalogAppLayoutBundleSlotVersion.*ORDER BY DefinitionVersion FOR UPDATE`).
		WithArgs(request.AppID, request.LocalComponentKey).
		WillReturnRows(rows)
}

func appInstallationColumns() []string {
	return []string{
		"AppInstallationId",
		"OrgId",
		"AppId",
		"State",
		"ActiveReleaseDigest",
		"CandidateReleaseDigest",
		"CatalogRevision",
		"ActivationEpoch",
		"ActiveOperationId",
		"LiveAppKey",
	}
}

func appOwnerDigest(digit string) string {
	return "sha256:" + strings.Repeat(digit, 64)
}

func assertVerificationExpectations(t *testing.T, mock sqlmock.Sqlmock) {
	t.Helper()
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatal(err)
	}
}
