package ledger

import (
	"context"
	"errors"
	"math/rand"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/shopspring/decimal"
	"github.com/stretchr/testify/require"
	"github.com/tech/sendico/ledger/storage"
	"github.com/tech/sendico/ledger/storage/model"
	"github.com/tech/sendico/pkg/db/transaction"
	"github.com/tech/sendico/pkg/merrors"
	pmodel "github.com/tech/sendico/pkg/model"
	"github.com/tech/sendico/pkg/model/account_role"
	moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
	ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.uber.org/zap"
)

type memoryTxFactory struct{}

func (memoryTxFactory) CreateTransaction() transaction.Transaction { return memoryTx{} }

type memoryTx struct{}

func (memoryTx) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
	return cb(ctx)
}

type memoryRepository struct {
	accounts       *memoryAccountsStore
	journalEntries *memoryJournalEntriesStore
	postingLines   *memoryPostingLinesStore
	balances       *memoryBalancesStore
	outbox         *memoryOutboxStore
	txFactory      transaction.Factory
}

func (r *memoryRepository) Ping(context.Context) error                  { return nil }
func (r *memoryRepository) Accounts() storage.AccountsStore             { return r.accounts }
func (r *memoryRepository) JournalEntries() storage.JournalEntriesStore { return r.journalEntries }
func (r *memoryRepository) PostingLines() storage.PostingLinesStore     { return r.postingLines }
func (r *memoryRepository) Balances() storage.BalancesStore             { return r.balances }
func (r *memoryRepository) Outbox() storage.OutboxStore                 { return r.outbox }
func (r *memoryRepository) TransactionFactory() transaction.Factory     { return r.txFactory }

type memoryAccountsStore struct {
	records            map[bson.ObjectID]*pmodel.LedgerAccount
	systemByPurposeKey map[string]*pmodel.LedgerAccount
}

func (s *memoryAccountsStore) Create(_ context.Context, account *pmodel.LedgerAccount) error {
	if account.GetID() == nil || account.GetID().IsZero() {
		account.SetID(bson.NewObjectID())
	}
	if s.records == nil {
		s.records = make(map[bson.ObjectID]*pmodel.LedgerAccount)
	}
	s.records[*account.GetID()] = account
	if account.SystemPurpose != nil {
		if s.systemByPurposeKey == nil {
			s.systemByPurposeKey = make(map[string]*pmodel.LedgerAccount)
		}
		key := string(*account.SystemPurpose) + "|" + account.Currency
		s.systemByPurposeKey[key] = account
	}
	return nil
}

func (s *memoryAccountsStore) Get(_ context.Context, accountRef bson.ObjectID) (*pmodel.LedgerAccount, error) {
	if s.records == nil {
		return nil, storage.ErrAccountNotFound
	}
	if acc, ok := s.records[accountRef]; ok {
		return acc, nil
	}
	return nil, storage.ErrAccountNotFound
}

func (s *memoryAccountsStore) GetByAccountCode(context.Context, bson.ObjectID, string, string) (*pmodel.LedgerAccount, error) {
	return nil, merrors.NotImplemented("get by code")
}

func (s *memoryAccountsStore) GetByRole(context.Context, bson.ObjectID, string, account_role.AccountRole) (*pmodel.LedgerAccount, error) {
	return nil, merrors.NotImplemented("get by role")
}

func (s *memoryAccountsStore) GetSystemAccount(_ context.Context, purpose pmodel.SystemAccountPurpose, currency string) (*pmodel.LedgerAccount, error) {
	if s.systemByPurposeKey == nil {
		return nil, storage.ErrAccountNotFound
	}
	key := string(purpose) + "|" + currency
	if acc, ok := s.systemByPurposeKey[key]; ok {
		return acc, nil
	}
	return nil, storage.ErrAccountNotFound
}

func (s *memoryAccountsStore) GetDefaultSettlement(context.Context, bson.ObjectID, string) (*pmodel.LedgerAccount, error) {
	return nil, merrors.NotImplemented("get default settlement")
}

func (s *memoryAccountsStore) ListByOrganization(context.Context, bson.ObjectID, *storage.AccountsFilter, int, int) ([]*pmodel.LedgerAccount, error) {
	return nil, merrors.NotImplemented("list by organization")
}

func (s *memoryAccountsStore) UpdateStatus(context.Context, bson.ObjectID, pmodel.LedgerAccountStatus) error {
	return merrors.NotImplemented("update status")
}

func (s *memoryAccountsStore) ListByCurrency(_ context.Context, currency string) ([]*pmodel.LedgerAccount, error) {
	accounts := make([]*pmodel.LedgerAccount, 0)
	for _, acc := range s.records {
		if acc == nil {
			continue
		}
		if acc.Currency != currency {
			continue
		}
		if acc.Scope != "" && acc.Scope != pmodel.LedgerAccountScopeOrganization {
			continue
		}
		accounts = append(accounts, acc)
	}
	return accounts, nil
}

type memoryJournalEntriesStore struct {
	byKey map[string]*model.JournalEntry
}

func (s *memoryJournalEntriesStore) Create(_ context.Context, entry *model.JournalEntry) error {
	if entry.GetID() == nil || entry.GetID().IsZero() {
		entry.SetID(bson.NewObjectID())
	}
	if s.byKey == nil {
		s.byKey = make(map[string]*model.JournalEntry)
	}
	key := entry.OrganizationRef.Hex() + "|" + entry.IdempotencyKey
	s.byKey[key] = entry
	return nil
}

func (s *memoryJournalEntriesStore) Get(context.Context, bson.ObjectID) (*model.JournalEntry, error) {
	return nil, merrors.NotImplemented("get entry")
}

func (s *memoryJournalEntriesStore) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, key string) (*model.JournalEntry, error) {
	if s.byKey == nil {
		return nil, storage.ErrJournalEntryNotFound
	}
	entry, ok := s.byKey[orgRef.Hex()+"|"+key]
	if !ok {
		return nil, storage.ErrJournalEntryNotFound
	}
	return entry, nil
}

func (s *memoryJournalEntriesStore) ListByOrganization(context.Context, bson.ObjectID, int, int) ([]*model.JournalEntry, error) {
	return nil, merrors.NotImplemented("list entries")
}

type memoryPostingLinesStore struct {
	lines []*model.PostingLine
}

func (s *memoryPostingLinesStore) CreateMany(_ context.Context, lines []*model.PostingLine) error {
	s.lines = append(s.lines, lines...)
	return nil
}

func (s *memoryPostingLinesStore) ListByJournalEntry(context.Context, bson.ObjectID) ([]*model.PostingLine, error) {
	return nil, merrors.NotImplemented("list lines by entry")
}

func (s *memoryPostingLinesStore) ListByAccount(context.Context, bson.ObjectID, int, int) ([]*model.PostingLine, error) {
	return nil, merrors.NotImplemented("list lines by account")
}

type memoryBalancesStore struct {
	records map[bson.ObjectID]*model.AccountBalance
}

func (s *memoryBalancesStore) Get(_ context.Context, accountRef bson.ObjectID) (*model.AccountBalance, error) {
	if s.records == nil {
		return nil, storage.ErrBalanceNotFound
	}
	if balance, ok := s.records[accountRef]; ok {
		copied := *balance
		return &copied, nil
	}
	return nil, storage.ErrBalanceNotFound
}

func (s *memoryBalancesStore) Upsert(_ context.Context, balance *model.AccountBalance) error {
	if s.records == nil {
		s.records = make(map[bson.ObjectID]*model.AccountBalance)
	}
	copied := *balance
	s.records[balance.AccountRef] = &copied
	return nil
}

func (s *memoryBalancesStore) IncrementBalance(context.Context, bson.ObjectID, string) error {
	return merrors.NotImplemented("increment balance")
}

type memoryOutboxStore struct{}

func (memoryOutboxStore) Create(context.Context, *model.OutboxEvent) error { return nil }
func (memoryOutboxStore) ListPending(context.Context, int) ([]*model.OutboxEvent, error) {
	return nil, merrors.NotImplemented("list outbox")
}
func (memoryOutboxStore) MarkSent(context.Context, bson.ObjectID, time.Time) error {
	return merrors.NotImplemented("mark sent")
}
func (memoryOutboxStore) MarkFailed(context.Context, bson.ObjectID) error {
	return merrors.NotImplemented("mark failed")
}
func (memoryOutboxStore) IncrementAttempts(context.Context, bson.ObjectID) error {
	return merrors.NotImplemented("increment attempts")
}

func newTestService() (*Service, *memoryRepository) {
	repo := &memoryRepository{
		accounts:       &memoryAccountsStore{},
		journalEntries: &memoryJournalEntriesStore{},
		postingLines:   &memoryPostingLinesStore{},
		balances:       &memoryBalancesStore{},
		outbox:         &memoryOutboxStore{},
		txFactory:      memoryTxFactory{},
	}
	svc := &Service{
		logger:  zap.NewNop(),
		storage: repo,
	}
	return svc, repo
}

func newOrgAccount(orgRef bson.ObjectID, currency string, role account_role.AccountRole) *pmodel.LedgerAccount {
	account := &pmodel.LedgerAccount{
		AccountCode:   "test:" + strings.ToLower(currency) + ":" + bson.NewObjectID().Hex(),
		Currency:      currency,
		AccountType:   pmodel.LedgerAccountTypeAsset,
		Status:        pmodel.LedgerAccountStatusActive,
		AllowNegative: false,
		Role:          role,
		Scope:         pmodel.LedgerAccountScopeOrganization,
	}
	account.OrganizationRef = &orgRef
	return account
}

func balanceString(t *testing.T, balances *memoryBalancesStore, accountID bson.ObjectID) string {
	t.Helper()
	bal, err := balances.Get(context.Background(), accountID)
	if errors.Is(err, storage.ErrBalanceNotFound) {
		return "0"
	}
	require.NoError(t, err)
	return bal.Balance
}

func balanceDecimal(t *testing.T, balances *memoryBalancesStore, accountID bson.ObjectID) decimal.Decimal {
	t.Helper()
	bal, err := balances.Get(context.Background(), accountID)
	if errors.Is(err, storage.ErrBalanceNotFound) {
		return decimal.Zero
	}
	require.NoError(t, err)
	dec, err := decimal.NewFromString(bal.Balance)
	require.NoError(t, err)
	return dec
}

func TestExternalCreditAndDebit(t *testing.T) {
	originalCurrencies := pmodel.SupportedCurrencies
	pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD}
	t.Cleanup(func() {
		pmodel.SupportedCurrencies = originalCurrencies
	})

	ctx := context.Background()
	svc, repo := newTestService()
	require.NoError(t, svc.ensureSystemAccounts(ctx))

	orgRef := bson.NewObjectID()
	pending := newOrgAccount(orgRef, "USD", account_role.AccountRolePending)
	require.NoError(t, repo.accounts.Create(ctx, pending))

	creditResp, err := svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
		IdempotencyKey:   "external-credit-1",
		OrganizationRef:  orgRef.Hex(),
		LedgerAccountRef: pending.GetID().Hex(),
		Money:            &moneyv1.Money{Currency: "USD", Amount: "100"},
	})
	require.NoError(t, err)
	require.NotEmpty(t, creditResp.GetJournalEntryRef())

	source, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, "USD")
	require.NoError(t, err)
	require.Equal(t, "100", balanceString(t, repo.balances, *pending.GetID()))
	require.Equal(t, "-100", balanceString(t, repo.balances, *source.GetID()))

	debitResp, err := svc.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{
		IdempotencyKey:   "external-debit-1",
		OrganizationRef:  orgRef.Hex(),
		LedgerAccountRef: pending.GetID().Hex(),
		Money:            &moneyv1.Money{Currency: "USD", Amount: "40"},
	})
	require.NoError(t, err)
	require.NotEmpty(t, debitResp.GetJournalEntryRef())

	sink, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSink, "USD")
	require.NoError(t, err)
	require.Equal(t, "60", balanceString(t, repo.balances, *pending.GetID()))
	require.Equal(t, "40", balanceString(t, repo.balances, *sink.GetID()))
}

func TestExternalCreditCurrencyMismatch(t *testing.T) {
	originalCurrencies := pmodel.SupportedCurrencies
	pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD}
	t.Cleanup(func() {
		pmodel.SupportedCurrencies = originalCurrencies
	})

	ctx := context.Background()
	svc, repo := newTestService()
	require.NoError(t, svc.ensureSystemAccounts(ctx))

	orgRef := bson.NewObjectID()
	pending := newOrgAccount(orgRef, "USD", account_role.AccountRolePending)
	require.NoError(t, repo.accounts.Create(ctx, pending))

	source, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, "USD")
	require.NoError(t, err)
	source.Currency = "EUR"

	_, err = svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
		IdempotencyKey:   "external-credit-mismatch",
		OrganizationRef:  orgRef.Hex(),
		LedgerAccountRef: pending.GetID().Hex(),
		Money:            &moneyv1.Money{Currency: "USD", Amount: "10"},
	})
	require.Error(t, err)
}

func TestExternalOperationsRejectSystemScopeTargets(t *testing.T) {
	originalCurrencies := pmodel.SupportedCurrencies
	pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD}
	t.Cleanup(func() {
		pmodel.SupportedCurrencies = originalCurrencies
	})

	ctx := context.Background()
	svc, repo := newTestService()
	require.NoError(t, svc.ensureSystemAccounts(ctx))

	orgRef := bson.NewObjectID()
	source, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, "USD")
	require.NoError(t, err)
	sink, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSink, "USD")
	require.NoError(t, err)

	_, err = svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
		IdempotencyKey:   "external-credit-system-target",
		OrganizationRef:  orgRef.Hex(),
		LedgerAccountRef: source.GetID().Hex(),
		Money:            &moneyv1.Money{Currency: "USD", Amount: "10"},
	})
	require.Error(t, err)

	_, err = svc.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{
		IdempotencyKey:   "external-debit-system-source",
		OrganizationRef:  orgRef.Hex(),
		LedgerAccountRef: sink.GetID().Hex(),
		Money:            &moneyv1.Money{Currency: "USD", Amount: "10"},
	})
	require.Error(t, err)
}

func TestExternalFlowInvariant(t *testing.T) {
	originalCurrencies := pmodel.SupportedCurrencies
	pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD}
	t.Cleanup(func() {
		pmodel.SupportedCurrencies = originalCurrencies
	})

	ctx := context.Background()
	svc, repo := newTestService()
	require.NoError(t, svc.ensureSystemAccounts(ctx))

	orgRef := bson.NewObjectID()
	pending := newOrgAccount(orgRef, "USD", account_role.AccountRolePending)
	transit := newOrgAccount(orgRef, "USD", account_role.AccountRoleTransit)
	require.NoError(t, repo.accounts.Create(ctx, pending))
	require.NoError(t, repo.accounts.Create(ctx, transit))

	_, err := svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
		IdempotencyKey:   "flow-credit",
		OrganizationRef:  orgRef.Hex(),
		LedgerAccountRef: pending.GetID().Hex(),
		Money:            &moneyv1.Money{Currency: "USD", Amount: "100"},
	})
	require.NoError(t, err)
	require.NoError(t, svc.CheckExternalInvariant(ctx, "USD"))

	_, err = svc.TransferInternal(ctx, &ledgerv1.TransferRequest{
		IdempotencyKey:       "flow-move",
		OrganizationRef:      orgRef.Hex(),
		FromLedgerAccountRef: pending.GetID().Hex(),
		ToLedgerAccountRef:   transit.GetID().Hex(),
		Money:                &moneyv1.Money{Currency: "USD", Amount: "100"},
	})
	require.NoError(t, err)
	require.NoError(t, svc.CheckExternalInvariant(ctx, "USD"))

	_, err = svc.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{
		IdempotencyKey:   "flow-debit",
		OrganizationRef:  orgRef.Hex(),
		LedgerAccountRef: transit.GetID().Hex(),
		Money:            &moneyv1.Money{Currency: "USD", Amount: "40"},
	})
	require.NoError(t, err)
	require.NoError(t, svc.CheckExternalInvariant(ctx, "USD"))
}

func TestExternalInvariantRandomSequence(t *testing.T) {
	originalCurrencies := pmodel.SupportedCurrencies
	pmodel.SupportedCurrencies = []pmodel.Currency{pmodel.CurrencyUSD}
	t.Cleanup(func() {
		pmodel.SupportedCurrencies = originalCurrencies
	})

	ctx := context.Background()
	svc, repo := newTestService()
	require.NoError(t, svc.ensureSystemAccounts(ctx))

	orgRef := bson.NewObjectID()
	pending := newOrgAccount(orgRef, "USD", account_role.AccountRolePending)
	transit := newOrgAccount(orgRef, "USD", account_role.AccountRoleTransit)
	require.NoError(t, repo.accounts.Create(ctx, pending))
	require.NoError(t, repo.accounts.Create(ctx, transit))

	rng := rand.New(rand.NewSource(42)) //nolint:gosec // deterministic pseudo-random sequence for invariant stress test
	for i := 0; i < 50; i++ {
		switch rng.Intn(3) {
		case 0:
			amount := rng.Intn(20) + 1
			_, err := svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
				IdempotencyKey:   "rand-credit-" + strconv.Itoa(i),
				OrganizationRef:  orgRef.Hex(),
				LedgerAccountRef: pending.GetID().Hex(),
				Money:            &moneyv1.Money{Currency: "USD", Amount: strconv.Itoa(amount)},
			})
			require.NoError(t, err)
		case 1:
			sourceID := *pending.GetID()
			destID := *transit.GetID()
			sourceBal := balanceDecimal(t, repo.balances, sourceID)
			if sourceBal.LessThanOrEqual(decimal.Zero) {
				sourceID = *transit.GetID()
				destID = *pending.GetID()
				sourceBal = balanceDecimal(t, repo.balances, sourceID)
			}
			if sourceBal.LessThanOrEqual(decimal.Zero) {
				continue
			}
			max := int(sourceBal.IntPart())
			amount := rng.Intn(max) + 1
			_, err := svc.TransferInternal(ctx, &ledgerv1.TransferRequest{
				IdempotencyKey:       "rand-move-" + strconv.Itoa(i),
				OrganizationRef:      orgRef.Hex(),
				FromLedgerAccountRef: sourceID.Hex(),
				ToLedgerAccountRef:   destID.Hex(),
				Money:                &moneyv1.Money{Currency: "USD", Amount: strconv.Itoa(amount)},
			})
			require.NoError(t, err)
		case 2:
			sourceID := *pending.GetID()
			sourceBal := balanceDecimal(t, repo.balances, sourceID)
			if sourceBal.LessThanOrEqual(decimal.Zero) {
				sourceID = *transit.GetID()
				sourceBal = balanceDecimal(t, repo.balances, sourceID)
			}
			if sourceBal.LessThanOrEqual(decimal.Zero) {
				continue
			}
			max := int(sourceBal.IntPart())
			amount := rng.Intn(max) + 1
			_, err := svc.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{
				IdempotencyKey:   "rand-debit-" + strconv.Itoa(i),
				OrganizationRef:  orgRef.Hex(),
				LedgerAccountRef: sourceID.Hex(),
				Money:            &moneyv1.Money{Currency: "USD", Amount: strconv.Itoa(amount)},
			})
			require.NoError(t, err)
		}
		require.NoError(t, svc.CheckExternalInvariant(ctx, "USD"))
	}
}
