package coredb

import (
	"context"
	"errors"
	"runtime"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
)

type withTxDBStub struct {
	beginErr error
	tx       *withTxTxStub
	options  TxOptions
	begins   int
}

func (db *withTxDBStub) BeginTx(_ context.Context, options TxOptions) (Tx, error) {
	db.begins++
	db.options = options
	if db.beginErr != nil {
		return nil, db.beginErr
	}
	return db.tx, nil
}

type withTxTxStub struct {
	commitErr   error
	rollbackErr error
	commits     int
	rollbacks   int
}

func (*withTxTxStub) QueryRow(context.Context, string, ...any) Row {
	return contractRowStub{}
}

func (*withTxTxStub) Query(context.Context, string, ...any) (Rows, error) {
	return &contractRowsStub{}, nil
}

func (*withTxTxStub) Exec(context.Context, string, ...any) (Result, error) {
	return contractResultStub{rowsAffected: 1}, nil
}

func (tx *withTxTxStub) Commit(context.Context) error {
	tx.commits++
	return tx.commitErr
}

func (tx *withTxTxStub) Rollback(context.Context) error {
	tx.rollbacks++
	return tx.rollbackErr
}

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

	ctx := context.Background()
	tx := &withTxTxStub{}
	db := &withTxDBStub{tx: tx}

	called := false
	err := WithTx(ctx, db, func(gotTx Tx) error {
		called = true
		require.Same(t, tx, gotTx)
		return nil
	})
	require.NoError(t, err)
	require.True(t, called)
	require.Equal(t, 1, db.begins)
	require.Equal(t, TxOptions{}, db.options)
	require.Equal(t, 1, tx.commits)
	require.Equal(t, 0, tx.rollbacks)
}

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

	ctx := context.Background()
	tx := &withTxTxStub{}
	db := &withTxDBStub{tx: tx}
	fnErr := errors.New("write failed")

	err := WithTx(ctx, db, func(Tx) error {
		return fnErr
	})
	require.ErrorIs(t, err, fnErr)
	require.Equal(t, 1, tx.rollbacks)
	require.Equal(t, 0, tx.commits)
}

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

	ctx := context.Background()
	tx := &withTxTxStub{}
	db := &withTxDBStub{tx: tx}

	require.PanicsWithValue(t, "boom", func() {
		_ = WithTx(ctx, db, func(Tx) error {
			panic("boom")
		})
	})
	require.Equal(t, 1, tx.rollbacks)
	require.Equal(t, 0, tx.commits)
}

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

	ctx := context.Background()
	tx := &withTxTxStub{}
	db := &withTxDBStub{tx: tx}

	done := make(chan struct{})
	go func() {
		defer close(done)
		_ = WithTx(ctx, db, func(Tx) error {
			runtime.Goexit()
			return nil
		})
	}()

	select {
	case <-done:
	case <-time.After(time.Second):
		t.Fatal("WithTx goroutine did not exit")
	}
	require.Equal(t, 1, tx.rollbacks)
	require.Equal(t, 0, tx.commits)
}

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

	ctx := context.Background()
	tx := &withTxTxStub{}
	db := &withTxDBStub{tx: tx}
	options := TxOptions{IsoLevel: Serializable, AccessMode: ReadOnly}

	err := WithTxOptions(ctx, db, options, func(Tx) error {
		return nil
	})
	require.NoError(t, err)
	require.Equal(t, options, db.options)
	require.Equal(t, 1, tx.commits)
}

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

	beginErr := errors.New("begin failed")
	db := &withTxDBStub{beginErr: beginErr}

	err := WithTx(context.Background(), db, func(Tx) error {
		return nil
	})
	require.ErrorIs(t, err, beginErr)
	require.EqualError(t, err, "begin tx: begin failed")
}

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

	err := WithTx(context.Background(), nil, func(Tx) error {
		return nil
	})
	require.EqualError(t, err, "coredb tx beginner is nil")

	err = WithTx(context.Background(), &withTxDBStub{}, nil)
	require.EqualError(t, err, "coredb tx function is nil")
}

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

	fnErr := errors.New("write failed")
	rollbackErr := errors.New("rollback failed")
	tx := &withTxTxStub{rollbackErr: rollbackErr}
	db := &withTxDBStub{tx: tx}

	err := WithTx(context.Background(), db, func(Tx) error {
		return fnErr
	})
	require.ErrorIs(t, err, fnErr)
	require.ErrorIs(t, err, rollbackErr)
	require.Contains(t, err.Error(), "rollback tx: rollback failed")
	require.Equal(t, 1, tx.rollbacks)
	require.Equal(t, 0, tx.commits)
}

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

	commitErr := errors.New("commit failed")
	rollbackErr := errors.New("rollback failed")
	tx := &withTxTxStub{commitErr: commitErr, rollbackErr: rollbackErr}
	db := &withTxDBStub{tx: tx}

	err := WithTx(context.Background(), db, func(Tx) error {
		return nil
	})
	require.ErrorIs(t, err, commitErr)
	require.ErrorIs(t, err, rollbackErr)
	require.Contains(t, err.Error(), "commit tx: commit failed")
	require.Contains(t, err.Error(), "rollback tx: rollback failed")
	require.Equal(t, 1, tx.commits)
	require.Equal(t, 1, tx.rollbacks)
}
