package fees

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

	"github.com/tech/sendico/billing/fees/internal/service/fees/types"
	"github.com/tech/sendico/billing/fees/storage"
	"github.com/tech/sendico/billing/fees/storage/model"
	oracleclient "github.com/tech/sendico/fx/oracle/client"
	me "github.com/tech/sendico/pkg/messaging/envelope"
	feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
	moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
	tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.uber.org/zap"
	"google.golang.org/protobuf/types/known/timestamppb"
)

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

	now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC)
	orgRef := bson.NewObjectID()

	plan := &model.FeePlan{
		Active:        true,
		EffectiveFrom: now.Add(-time.Hour),
		Rules: []model.FeeRule{
			{
				RuleID:           "capture_default",
				Trigger:          model.TriggerCapture,
				Priority:         10,
				Percentage:       "0.029",
				FixedAmount:      "0.30",
				LedgerAccountRef: "acct:fees",
				LineType:         "fee",
				EntrySide:        "credit",
				Rounding:         "half_up",
				Metadata: map[string]string{
					"scale":    "2",
					"tax_code": "VAT",
					"tax_rate": "0.20",
				},
				EffectiveFrom: now.Add(-time.Hour),
			},
		},
	}
	plan.SetID(bson.NewObjectID())
	plan.OrganizationRef = &orgRef

	service := NewService(
		zap.NewNop(),
		&stubRepository{plans: &stubPlansStore{plan: plan}},
		noopProducer{},
		WithClock(fixedClock{now: now}),
	)

	req := &feesv1.QuoteFeesRequest{
		Meta: &feesv1.RequestMeta{
			OrganizationRef: orgRef.Hex(),
			Trace: &tracev1.TraceContext{
				TraceRef: "trace-capture",
			},
		},
		Intent: &feesv1.Intent{
			Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
			BaseAmount: &moneyv1.Money{
				Amount:   "100.00",
				Currency: "USD",
			},
			BookedAt:   timestamppb.New(now),
			Attributes: map[string]string{"channel": "card"},
		},
	}

	resp, err := service.QuoteFees(context.Background(), req)
	if err != nil {
		t.Fatalf("QuoteFees returned error: %v", err)
	}

	if resp.GetMeta().GetTrace().GetTraceRef() != "trace-capture" {
		t.Fatalf("expected trace_ref to round-trip, got %q", resp.GetMeta().GetTrace().GetTraceRef())
	}

	if len(resp.GetLines()) != 1 {
		t.Fatalf("expected 1 derived line, got %d", len(resp.GetLines()))
	}

	line := resp.GetLines()[0]
	if got := line.GetMoney().GetAmount(); got != "3.20" {
		t.Fatalf("expected fee amount 3.20, got %s", got)
	}

	if line.GetMoney().GetCurrency() != "USD" {
		t.Fatalf("expected currency USD, got %s", line.GetMoney().GetCurrency())
	}

	if line.GetLedgerAccountRef() != "acct:fees" {
		t.Fatalf("unexpected ledger account ref %s", line.GetLedgerAccountRef())
	}
	if meta := line.GetMeta(); meta["fee_rule_id"] != "capture_default" || meta["fee_plan_id"] != plan.GetID().Hex() || meta["tax_code"] != "VAT" {
		t.Fatalf("unexpected derived line metadata: %#v", meta)
	}

	if len(resp.GetApplied()) != 1 {
		t.Fatalf("expected 1 applied rule, got %d", len(resp.GetApplied()))
	}

	applied := resp.GetApplied()[0]
	if applied.GetTaxCode() != "VAT" || applied.GetTaxRate() != "0.20" {
		t.Fatalf("applied rule metadata mismatch: %+v", applied)
	}

	if applied.GetRounding() != moneyv1.RoundingMode_ROUND_HALF_UP {
		t.Fatalf("expected rounding HALF_UP, got %v", applied.GetRounding())
	}

	if applied.GetParameters()["scale"] != "2" {
		t.Fatalf("expected parameters to carry metadata scale, got %+v", applied.GetParameters())
	}
}

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

	now := time.Date(2024, 5, 20, 9, 30, 0, 0, time.UTC)
	orgRef := bson.NewObjectID()

	plan := &model.FeePlan{
		Active:        true,
		EffectiveFrom: now.Add(-24 * time.Hour),
		Rules: []model.FeeRule{
			{
				RuleID:           "base",
				Trigger:          model.TriggerCapture,
				Priority:         1,
				Percentage:       "0.10",
				LedgerAccountRef: "acct:base",
				Metadata:         map[string]string{"scale": "2"},
				Rounding:         "half_even",
				EffectiveFrom:    now.Add(-time.Hour),
			},
			{
				RuleID:           "future",
				Trigger:          model.TriggerCapture,
				Priority:         2,
				Percentage:       "0.50",
				LedgerAccountRef: "acct:future",
				Metadata:         map[string]string{"scale": "2"},
				Rounding:         "half_even",
				EffectiveFrom:    now.Add(time.Hour),
			},
			{
				RuleID:           "attr",
				Trigger:          model.TriggerCapture,
				Priority:         3,
				Percentage:       "0.30",
				LedgerAccountRef: "acct:attr",
				Metadata:         map[string]string{"scale": "2"},
				AppliesTo:        map[string]string{"region": "eu"},
				Rounding:         "half_even",
				EffectiveFrom:    now.Add(-time.Hour),
			},
		},
	}
	plan.SetID(bson.NewObjectID())
	plan.OrganizationRef = &orgRef

	service := NewService(
		zap.NewNop(),
		&stubRepository{plans: &stubPlansStore{plan: plan}},
		noopProducer{},
		WithClock(fixedClock{now: now}),
	)

	req := &feesv1.QuoteFeesRequest{
		Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
		Intent: &feesv1.Intent{
			Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
			BaseAmount: &moneyv1.Money{
				Amount:   "50.00",
				Currency: "EUR",
			},
			BookedAt:   timestamppb.New(now),
			Attributes: map[string]string{"region": "us"},
		},
	}

	resp, err := service.QuoteFees(context.Background(), req)
	if err != nil {
		t.Fatalf("QuoteFees returned error: %v", err)
	}

	if len(resp.GetLines()) != 1 {
		t.Fatalf("expected only base rule to fire, got %d lines", len(resp.GetLines()))
	}

	line := resp.GetLines()[0]
	if line.GetLedgerAccountRef() != "acct:base" {
		t.Fatalf("expected base rule to apply, got %s", line.GetLedgerAccountRef())
	}

	if line.GetMoney().GetAmount() != "5.00" {
		t.Fatalf("expected 5.00 amount, got %s", line.GetMoney().GetAmount())
	}
}

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

	now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
	orgRef := bson.NewObjectID()

	plan := &model.FeePlan{
		Active:        true,
		EffectiveFrom: now.Add(-time.Hour),
		Rules: []model.FeeRule{
			{
				RuleID:           "round_down",
				Trigger:          model.TriggerCapture,
				Priority:         1,
				FixedAmount:      "0.015",
				LedgerAccountRef: "acct:round",
				Metadata:         map[string]string{"scale": "2"},
				Rounding:         "down",
				EffectiveFrom:    now.Add(-time.Hour),
			},
		},
	}
	plan.SetID(bson.NewObjectID())
	plan.OrganizationRef = &orgRef

	service := NewService(
		zap.NewNop(),
		&stubRepository{plans: &stubPlansStore{plan: plan}},
		noopProducer{},
		WithClock(fixedClock{now: now}),
	)

	req := &feesv1.QuoteFeesRequest{
		Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
		Intent: &feesv1.Intent{
			Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
			BaseAmount: &moneyv1.Money{
				Amount:   "1.00",
				Currency: "USD",
			},
			BookedAt: timestamppb.New(now),
		},
	}

	resp, err := service.QuoteFees(context.Background(), req)
	if err != nil {
		t.Fatalf("QuoteFees returned error: %v", err)
	}

	if len(resp.GetLines()) != 1 {
		t.Fatalf("expected single derived line, got %d", len(resp.GetLines()))
	}

	if resp.GetLines()[0].GetMoney().GetAmount() != "0.01" {
		t.Fatalf("expected rounding down to 0.01, got %s", resp.GetLines()[0].GetMoney().GetAmount())
	}
}

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

	now := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC)
	orgRef := bson.NewObjectID()
	plan := &model.FeePlan{
		Active:        true,
		EffectiveFrom: now.Add(-time.Hour),
		Rules: []model.FeeRule{
			{
				RuleID:           "stub",
				Trigger:          model.TriggerCapture,
				Priority:         1,
				Percentage:       "0.01",
				LedgerAccountRef: "acct:stub",
				EffectiveFrom:    now.Add(-time.Hour),
			},
		},
	}
	plan.SetID(bson.NewObjectID())
	plan.OrganizationRef = &orgRef

	result := &types.CalculationResult{
		Lines: []*feesv1.DerivedPostingLine{
			{
				LedgerAccountRef: "acct:stub",
				Money: &moneyv1.Money{
					Amount:   "1.23",
					Currency: "USD",
				},
			},
		},
		Applied: []*feesv1.AppliedRule{
			{RuleId: "stub"},
		},
	}
	calc := &stubCalculator{result: result}

	service := NewService(
		zap.NewNop(),
		&stubRepository{plans: &stubPlansStore{plan: plan}},
		noopProducer{},
		WithClock(fixedClock{now: now}),
		WithCalculator(calc),
	)

	resp, err := service.QuoteFees(context.Background(), &feesv1.QuoteFeesRequest{
		Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
		Intent: &feesv1.Intent{
			Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
			BaseAmount: &moneyv1.Money{
				Amount:   "10.00",
				Currency: "USD",
			},
		},
	})
	if err != nil {
		t.Fatalf("QuoteFees returned error: %v", err)
	}

	if !calc.called {
		t.Fatalf("expected calculator to be invoked")
	}

	if calc.gotPlan != plan {
		t.Fatalf("expected calculator to receive plan pointer")
	}

	if len(resp.GetLines()) != len(result.Lines) {
		t.Fatalf("expected %d lines, got %d", len(result.Lines), len(resp.GetLines()))
	}

	if resp.GetLines()[0].GetLedgerAccountRef() != "acct:stub" {
		t.Fatalf("unexpected ledger account in response: %s", resp.GetLines()[0].GetLedgerAccountRef())
	}
}

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

	now := time.Date(2024, 7, 1, 9, 30, 0, 0, time.UTC)
	orgRef := bson.NewObjectID()

	plan := &model.FeePlan{
		Active:        true,
		EffectiveFrom: now.Add(-time.Hour),
		Rules: []model.FeeRule{
			{
				RuleID:           "fx_mark_up",
				Trigger:          model.TriggerFXConversion,
				Priority:         1,
				Percentage:       "0.03",
				LedgerAccountRef: "acct:fx",
				Metadata:         map[string]string{"scale": "2"},
				Rounding:         "half_even",
				EffectiveFrom:    now.Add(-time.Hour),
			},
		},
	}
	plan.SetID(bson.NewObjectID())
	plan.OrganizationRef = &orgRef

	fakeOracle := &oracleclient.Fake{
		LatestRateFn: func(_ context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
			return &oracleclient.RateSnapshot{
				Pair:      req.Pair,
				Mid:       "1.2300",
				SpreadBps: "12",
				Provider:  "TestProvider",
				RateRef:   "rate-ref-123",
				AsOf:      now.Add(-2 * time.Minute),
			}, nil
		},
	}

	service := NewService(
		zap.NewNop(),
		&stubRepository{plans: &stubPlansStore{plan: plan}},
		noopProducer{},
		WithClock(fixedClock{now: now}),
		WithOracleClient(fakeOracle),
	)

	resp, err := service.QuoteFees(context.Background(), &feesv1.QuoteFeesRequest{
		Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
		Intent: &feesv1.Intent{
			Trigger: feesv1.Trigger_TRIGGER_FX_CONVERSION,
			BaseAmount: &moneyv1.Money{
				Amount:   "100.00",
				Currency: "USD",
			},
			Attributes: map[string]string{
				"fx_base_currency":  "USD",
				"fx_quote_currency": "EUR",
				"fx_provider":       "TestProvider",
				"fx_side":           "buy_base",
			},
		},
	})
	if err != nil {
		t.Fatalf("QuoteFees returned error: %v", err)
	}

	if resp.GetFxUsed() == nil {
		t.Fatalf("expected FxUsed to be populated")
	}

	fx := resp.GetFxUsed()
	if fx.GetProvider() != "TestProvider" || fx.GetRate().GetValue() != "1.2300" {
		t.Fatalf("unexpected FxUsed payload: %+v", fx)
	}

	if fx.GetPair().GetBase() != "USD" || fx.GetPair().GetQuote() != "EUR" {
		t.Fatalf("unexpected currency pair: %+v", fx.GetPair())
	}
}

type stubRepository struct {
	plans storage.PlansStore
}

func (s *stubRepository) Ping(context.Context) error {
	return nil
}

func (s *stubRepository) Plans() storage.PlansStore {
	return s.plans
}

type stubPlansStore struct {
	plan       *model.FeePlan
	globalPlan *model.FeePlan
}

func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error {
	return nil
}

func (s *stubPlansStore) Update(context.Context, *model.FeePlan) error {
	return nil
}

func (s *stubPlansStore) Get(context.Context, bson.ObjectID) (*model.FeePlan, error) {
	return nil, storage.ErrFeePlanNotFound
}

func (s *stubPlansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
	if !orgRef.IsZero() {
		if plan, err := s.FindActiveOrgPlan(ctx, orgRef, asOf); err == nil {
			return plan, nil
		} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
			return nil, err
		}
	}

	return s.FindActiveGlobalPlan(ctx, asOf)
}

func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
	if s.plan == nil {
		return nil, storage.ErrFeePlanNotFound
	}

	if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) {
		return nil, storage.ErrFeePlanNotFound
	}

	if !s.plan.Active {
		return nil, storage.ErrFeePlanNotFound
	}

	if s.plan.EffectiveFrom.After(asOf) {
		return nil, storage.ErrFeePlanNotFound
	}

	if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(asOf) {
		return nil, storage.ErrFeePlanNotFound
	}

	return s.plan, nil
}

func (s *stubPlansStore) FindActiveGlobalPlan(_ context.Context, asOf time.Time) (*model.FeePlan, error) {
	if s.globalPlan == nil {
		return nil, storage.ErrFeePlanNotFound
	}

	if !s.globalPlan.Active {
		return nil, storage.ErrFeePlanNotFound
	}

	if s.globalPlan.EffectiveFrom.After(asOf) {
		return nil, storage.ErrFeePlanNotFound
	}

	if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(asOf) {
		return nil, storage.ErrFeePlanNotFound
	}

	return s.globalPlan, nil
}

type noopProducer struct{}

func (noopProducer) SendMessage(me.Envelope) error {
	return nil
}

type fixedClock struct {
	now time.Time
}

func (f fixedClock) Now() time.Time {
	return f.now
}

type stubCalculator struct {
	result   *types.CalculationResult
	err      error
	called   bool
	gotPlan  *model.FeePlan
	bookedAt time.Time
}

func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
	s.called = true
	s.gotPlan = plan
	s.bookedAt = bookedAt

	if s.err != nil {
		return nil, s.err
	}

	return s.result, nil
}
