package transaction

import (
	"context"
	"database/sql"
	"database/sql/driver"
	"errors"
	"io"
	"strings"
	"sync"
	"testing"

	"github.com/stretchr/testify/require"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

func TestProviderDBUsesContextOutsideTransaction(t *testing.T) {
	t.Parallel()

	gormDB, _ := newTestGormDB(t)
	provider := NewProvider(gormDB)
	ctx := context.WithValue(context.Background(), contextKey("request-id"), "test-request")

	got := provider.DB(ctx)

	require.NotSame(t, gormDB, got)
	require.Same(t, ctx, got.Statement.Context)
}

func TestProviderTransactionCommitsAndUsesTransactionDB(t *testing.T) {
	t.Parallel()

	gormDB, recorder := newTestGormDB(t)
	provider := NewProvider(gormDB)

	err := provider.Transaction(context.Background(), func(ctx context.Context) error {
		txDB := provider.DB(ctx)
		require.NotSame(t, gormDB, txDB)

		return txDB.Exec("INSERT INTO provider_test VALUES (?)", 1).Error
	})

	require.NoError(t, err)
	require.Equal(t, []string{
		"begin",
		"exec:INSERT INTO provider_test VALUES (?)",
		"commit",
	}, recorder.snapshot())
}

func TestProviderTransactionRollsBackOnCallbackError(t *testing.T) {
	t.Parallel()

	gormDB, recorder := newTestGormDB(t)
	provider := NewProvider(gormDB)
	expectedErr := errors.New("callback failed")

	err := provider.Transaction(context.Background(), func(ctx context.Context) error {
		return expectedErr
	})

	require.ErrorIs(t, err, expectedErr)
	require.Equal(t, []string{"begin", "rollback"}, recorder.snapshot())
}

func TestProviderTransactionReusesCurrentTransactionWhenNestedDisabled(t *testing.T) {
	t.Parallel()

	gormDB, recorder := newTestGormDB(t)
	provider := NewProvider(gormDB)

	err := provider.Transaction(context.Background(), func(ctx context.Context) error {
		outerDB := provider.DB(ctx)

		return provider.Transaction(ctx, func(nestedCtx context.Context) error {
			require.Same(t, outerDB, provider.DB(nestedCtx))
			return nil
		})
	})

	require.NoError(t, err)
	require.Equal(t, []string{"begin", "commit"}, recorder.snapshot())
}

func TestProviderTransactionUsesSavePointWhenNestedEnabled(t *testing.T) {
	t.Parallel()

	gormDB, recorder := newTestGormDB(t)
	provider := NewProvider(gormDB, WithNestedTransaction())
	expectedErr := errors.New("nested failed")

	err := provider.Transaction(context.Background(), func(ctx context.Context) error {
		outerDB := provider.DB(ctx)

		err := provider.Transaction(ctx, func(nestedCtx context.Context) error {
			require.NotSame(t, outerDB, provider.DB(nestedCtx))
			return expectedErr
		})
		require.ErrorIs(t, err, expectedErr)

		return nil
	})

	require.NoError(t, err)

	events := recorder.snapshot()
	require.Len(t, events, 4)
	require.Equal(t, "begin", events[0])
	require.True(t, strings.HasPrefix(events[1], "exec:SAVEPOINT "), events)
	require.True(t, strings.HasPrefix(events[2], "exec:ROLLBACK TO SAVEPOINT "), events)
	require.Equal(t, "commit", events[3])
}

type contextKey string

var (
	registerTestDriverOnce sync.Once
	testRecorders          sync.Map
)

func newTestGormDB(t *testing.T) (*gorm.DB, *transactionRecorder) {
	t.Helper()

	registerTestDriverOnce.Do(func() {
		sql.Register("provider_transaction_test", testDriver{})
	})

	name := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
	recorder := &transactionRecorder{}
	testRecorders.Store(name, recorder)
	t.Cleanup(func() {
		testRecorders.Delete(name)
	})

	sqlDB, err := sql.Open("provider_transaction_test", name)
	require.NoError(t, err)
	t.Cleanup(func() {
		require.NoError(t, sqlDB.Close())
	})

	gormDB, err := gorm.Open(mysql.New(mysql.Config{
		Conn:                      sqlDB,
		SkipInitializeWithVersion: true,
	}), &gorm.Config{})
	require.NoError(t, err)

	return gormDB, recorder
}

type transactionRecorder struct {
	mu     sync.Mutex
	events []string
}

func (r *transactionRecorder) record(event string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.events = append(r.events, event)
}

func (r *transactionRecorder) snapshot() []string {
	r.mu.Lock()
	defer r.mu.Unlock()
	return append([]string(nil), r.events...)
}

type testDriver struct{}

func (testDriver) Open(name string) (driver.Conn, error) {
	recorder, ok := testRecorders.Load(name)
	if !ok {
		return nil, errors.New("missing recorder")
	}

	return &testConn{recorder: recorder.(*transactionRecorder)}, nil
}

type testConn struct {
	recorder *transactionRecorder
}

func (c *testConn) Prepare(query string) (driver.Stmt, error) {
	return testStmt{query: query, recorder: c.recorder}, nil
}

func (c *testConn) Close() error {
	return nil
}

func (c *testConn) Begin() (driver.Tx, error) {
	c.recorder.record("begin")
	return testTx{recorder: c.recorder}, nil
}

func (c *testConn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) {
	c.recorder.record("begin")
	return testTx{recorder: c.recorder}, nil
}

func (c *testConn) ExecContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Result, error) {
	c.recorder.record("exec:" + query)
	return driver.RowsAffected(1), nil
}

func (c *testConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) {
	c.recorder.record("query:" + query)
	return testRows{}, nil
}

type testTx struct {
	recorder *transactionRecorder
}

func (tx testTx) Commit() error {
	tx.recorder.record("commit")
	return nil
}

func (tx testTx) Rollback() error {
	tx.recorder.record("rollback")
	return nil
}

type testStmt struct {
	query    string
	recorder *transactionRecorder
}

func (s testStmt) Close() error {
	return nil
}

func (s testStmt) NumInput() int {
	return -1
}

func (s testStmt) Exec([]driver.Value) (driver.Result, error) {
	s.recorder.record("exec:" + s.query)
	return driver.RowsAffected(1), nil
}

func (s testStmt) Query([]driver.Value) (driver.Rows, error) {
	s.recorder.record("query:" + s.query)
	return testRows{}, nil
}

type testRows struct{}

func (testRows) Columns() []string {
	return nil
}

func (testRows) Close() error {
	return nil
}

func (testRows) Next([]driver.Value) error {
	return io.EOF
}
