package service

import (
	"context"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
	"gorm.io/gorm"

	"github.com/pocket-id/pocket-id/backend/internal/model"
	testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)

func newTestAppLockService(t *testing.T, db *gorm.DB) *AppLockService {
	t.Helper()

	return &AppLockService{
		db:        db,
		processID: 1,
		hostID:    "test-host",
		lockID:    "a13c7673-c7ae-49f1-9112-2cd2d0d4b0c1",
	}
}

func insertLock(t *testing.T, db *gorm.DB, value lockValue) {
	t.Helper()

	raw, err := value.Marshal()
	require.NoError(t, err)

	err = db.Create(&model.KV{Key: lockKey, Value: &raw}).Error
	require.NoError(t, err)
}

func readLockValue(t *testing.T, db *gorm.DB) lockValue {
	t.Helper()

	var row model.KV
	err := db.Take(&row, "key = ?", lockKey).Error
	require.NoError(t, err)

	require.NotNil(t, row.Value)

	var value lockValue
	err = value.Unmarshal(*row.Value)
	require.NoError(t, err)

	return value
}

func lockDatabaseForWrite(t *testing.T, db *gorm.DB) *gorm.DB {
	t.Helper()

	tx := db.Begin()
	require.NoError(t, tx.Error)

	// Keep a write transaction open to block other queries.
	err := tx.Exec(
		`INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`,
		lockKey,
		`{"expires_at":0}`,
	).Error
	require.NoError(t, err)

	return tx
}

func TestAppLockServiceAcquire(t *testing.T) {
	t.Run("creates new lock when none exists", func(t *testing.T) {
		db := testutils.NewDatabaseForTest(t)
		service := newTestAppLockService(t, db)

		_, err := service.Acquire(context.Background(), false)
		require.NoError(t, err)

		stored := readLockValue(t, db)
		require.Equal(t, service.processID, stored.ProcessID)
		require.Equal(t, service.hostID, stored.HostID)
		require.Greater(t, stored.ExpiresAt, time.Now().Unix())
	})

	t.Run("returns ErrLockUnavailable when lock held by another process", func(t *testing.T) {
		db := testutils.NewDatabaseForTest(t)
		service := newTestAppLockService(t, db)

		existing := lockValue{
			ProcessID: 99,
			HostID:    "other-host",
			ExpiresAt: time.Now().Add(ttl).Unix(),
		}
		insertLock(t, db, existing)

		_, err := service.Acquire(context.Background(), false)
		require.ErrorIs(t, err, ErrLockUnavailable)

		current := readLockValue(t, db)
		require.Equal(t, existing, current)
	})

	t.Run("force acquisition steals lock", func(t *testing.T) {
		db := testutils.NewDatabaseForTest(t)
		service := newTestAppLockService(t, db)

		insertLock(t, db, lockValue{
			ProcessID: 99,
			HostID:    "other-host",
			ExpiresAt: time.Now().Unix(),
		})

		_, err := service.Acquire(context.Background(), true)
		require.NoError(t, err)

		stored := readLockValue(t, db)
		require.Equal(t, service.processID, stored.ProcessID)
		require.Equal(t, service.hostID, stored.HostID)
		require.Greater(t, stored.ExpiresAt, time.Now().Unix())
	})

	t.Run("force acquisition returns wait duration when stealing active lock", func(t *testing.T) {
		db := testutils.NewDatabaseForTest(t)
		service := newTestAppLockService(t, db)

		existing := lockValue{
			ProcessID: 99,
			HostID:    "other-host",
			LockID:    "other-lock-id",
			ExpiresAt: time.Now().Add(ttl).Unix(),
		}
		insertLock(t, db, existing)

		waitUntil, err := service.Acquire(context.Background(), true)
		require.NoError(t, err)
		require.WithinDuration(t, time.Unix(existing.ExpiresAt, 0), waitUntil, time.Second)
	})

	t.Run("force acquisition does not wait when lock id is unchanged", func(t *testing.T) {
		db := testutils.NewDatabaseForTest(t)
		service := newTestAppLockService(t, db)

		insertLock(t, db, lockValue{
			ProcessID: 99,
			HostID:    "other-host",
			LockID:    service.lockID,
			ExpiresAt: time.Now().Add(ttl).Unix(),
		})

		waitUntil, err := service.Acquire(context.Background(), true)
		require.NoError(t, err)
		require.True(t, waitUntil.IsZero())
	})

	t.Run("returns error when existing lock value is invalid JSON", func(t *testing.T) {
		db := testutils.NewDatabaseForTest(t)
		service := newTestAppLockService(t, db)

		err := db.Create(&model.KV{Key: lockKey, Value: new("this-is-not-json")}).Error
		require.NoError(t, err)

		_, err = service.Acquire(context.Background(), false)
		require.ErrorContains(t, err, "decode existing lock value")
	})

	t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
		db := testutils.NewDatabaseForTest(t)
		service := newTestAppLockService(t, db)

		tx := lockDatabaseForWrite(t, db)
		defer tx.Rollback()

		ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
		defer cancel()

		_, err := service.Acquire(ctx, false)
		require.ErrorIs(t, err, context.DeadlineExceeded)
		require.ErrorContains(t, err, "begin lock transaction")
	})
}

func TestAppLockServiceRelease(t *testing.T) {
	t.Run("removes owned lock", func(t *testing.T) {
		db := testutils.NewDatabaseForTest(t)
		service := newTestAppLockService(t, db)

		_, err := service.Acquire(context.Background(), false)
		require.NoError(t, err)

		err = service.Release(context.Background())
		require.NoError(t, err)

		var row model.KV
		err = db.Take(&row, "key = ?", lockKey).Error
		require.ErrorIs(t, err, gorm.ErrRecordNotFound)
	})

	t.Run("ignores lock held by another owner", func(t *testing.T) {
		db := testutils.NewDatabaseForTest(t)
		service := newTestAppLockService(t, db)

		existing := lockValue{
			ProcessID: 2,
			HostID:    "other-host",
			ExpiresAt: time.Now().Add(ttl).Unix(),
		}
		insertLock(t, db, existing)

		err := service.Release(context.Background())
		require.NoError(t, err)

		stored := readLockValue(t, db)
		require.Equal(t, existing, stored)
	})

	t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
		db := testutils.NewDatabaseForTest(t)
		service := newTestAppLockService(t, db)

		_, err := service.Acquire(context.Background(), false)
		require.NoError(t, err)

		tx := lockDatabaseForWrite(t, db)
		defer tx.Rollback()

		ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
		defer cancel()

		err = service.Release(ctx)
		require.ErrorIs(t, err, context.DeadlineExceeded)
		require.ErrorContains(t, err, "release lock failed")
	})
}

func TestAppLockServiceRenew(t *testing.T) {
	t.Run("extends expiration when lock is still owned", func(t *testing.T) {
		db := testutils.NewDatabaseForTest(t)
		service := newTestAppLockService(t, db)

		_, err := service.Acquire(context.Background(), false)
		require.NoError(t, err)

		before := readLockValue(t, db)

		err = service.renew(context.Background())
		require.NoError(t, err)

		after := readLockValue(t, db)
		require.Equal(t, service.processID, after.ProcessID)
		require.Equal(t, service.hostID, after.HostID)
		require.GreaterOrEqual(t, after.ExpiresAt, before.ExpiresAt)
	})

	t.Run("returns ErrLockLost when lock is missing", func(t *testing.T) {
		db := testutils.NewDatabaseForTest(t)
		service := newTestAppLockService(t, db)

		err := service.renew(context.Background())
		require.ErrorIs(t, err, ErrLockLost)
	})

	t.Run("returns ErrLockLost when ownership changed", func(t *testing.T) {
		db := testutils.NewDatabaseForTest(t)
		service := newTestAppLockService(t, db)

		_, err := service.Acquire(context.Background(), false)
		require.NoError(t, err)

		// Simulate a different process taking the lock.
		newOwner := lockValue{
			ProcessID: 9,
			HostID:    "stolen-host",
			ExpiresAt: time.Now().Add(ttl).Unix(),
		}
		raw, marshalErr := newOwner.Marshal()
		require.NoError(t, marshalErr)
		updateErr := db.Model(&model.KV{}).
			Where("key = ?", lockKey).
			Update("value", raw).Error
		require.NoError(t, updateErr)

		err = service.renew(context.Background())
		require.ErrorIs(t, err, ErrLockLost)
	})

	t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
		db := testutils.NewDatabaseForTest(t)
		service := newTestAppLockService(t, db)

		_, err := service.Acquire(context.Background(), false)
		require.NoError(t, err)

		tx := lockDatabaseForWrite(t, db)
		defer tx.Rollback()

		ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
		defer cancel()

		err = service.renew(ctx)
		require.ErrorIs(t, err, context.DeadlineExceeded)
	})
}
