package calculator

import (
	"context"
	"errors"
	"maps"
	"math/big"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/tech/sendico/billing/fees/internal/service/fees/types"
	"github.com/tech/sendico/billing/fees/storage/model"
	oracleclient "github.com/tech/sendico/fx/oracle/client"
	dmath "github.com/tech/sendico/pkg/decimal"
	"github.com/tech/sendico/pkg/merrors"
	"github.com/tech/sendico/pkg/mlogger"
	feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
	accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
	fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
	moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
	tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
	"go.uber.org/zap"
)

// fxOracle captures the oracle dependency for FX conversions.
type fxOracle interface {
	LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
}

// New constructs the default calculator implementation.
func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator {
	if logger == nil {
		logger = zap.NewNop()
	}

	return &quoteCalculator{
		logger: logger.Named("calculator"),
		oracle: oracle,
	}
}

type quoteCalculator struct {
	logger mlogger.Logger
	oracle fxOracle
}

func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
	baseAmount, baseScale, trigger, err := validateComputeInputs(plan, intent)
	if err != nil {
		return nil, err
	}

	rules := make([]model.FeeRule, len(plan.Rules))
	copy(rules, plan.Rules)
	sort.SliceStable(rules, func(i, j int) bool {
		if rules[i].Priority == rules[j].Priority {
			return rules[i].RuleID < rules[j].RuleID
		}

		return rules[i].Priority < rules[j].Priority
	})

	planID := planIDFrom(plan)
	lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
	applied := make([]*feesv1.AppliedRule, 0, len(rules))

	for _, rule := range rules {
		if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
			continue
		}

		amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
		if calcErr != nil {
			if !errors.Is(calcErr, merrors.ErrInvalidArg) {
				c.logger.Warn("Failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
			}

			continue
		}

		if amount.Sign() == 0 {
			continue
		}

		currency := resolvedCurrency(intent.GetBaseAmount().GetCurrency(), rule.Currency)
		entrySide := resolvedEntrySide(rule)

		lines = append(lines, buildPostingLine(rule, amount, scale, currency, entrySide, planID))
		applied = append(applied, buildAppliedRule(rule, planID))
	}

	var fxUsed *feesv1.FXUsed
	if trigger == model.TriggerFXConversion && c.oracle != nil {
		fxUsed = c.buildFxUsed(ctx, intent)
	}

	return &types.CalculationResult{
		Lines:   lines,
		Applied: applied,
		FxUsed:  fxUsed,
	}, nil
}

func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
	scale, err := resolveRuleScale(rule, baseScale)
	if err != nil {
		return nil, 0, err
	}

	result := new(big.Rat)

	result, err = applyPercentage(result, baseAmount, rule.Percentage)
	if err != nil {
		return nil, 0, err
	}

	result, err = applyFixed(result, rule.FixedAmount)
	if err != nil {
		return nil, 0, err
	}

	result, err = applyMin(result, rule.MinimumAmount)
	if err != nil {
		return nil, 0, err
	}

	result, err = applyMax(result, rule.MaximumAmount)
	if err != nil {
		return nil, 0, err
	}

	if result.Sign() < 0 {
		result = new(big.Rat).Abs(result)
	}

	rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
	if rerr != nil {
		return nil, 0, rerr
	}

	return rounded, scale, nil
}

func applyPercentage(result, baseAmount *big.Rat, percentage string) (*big.Rat, error) {
	if strings.TrimSpace(percentage) == "" {
		return result, nil
	}

	percentageRat, err := dmath.RatFromString(percentage)
	if err != nil {
		return nil, merrors.InvalidArgument("invalid percentage")
	}

	return dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat)), nil
}

func applyFixed(result *big.Rat, fixed string) (*big.Rat, error) {
	if strings.TrimSpace(fixed) == "" {
		return result, nil
	}

	fixedRat, err := dmath.RatFromString(fixed)
	if err != nil {
		return nil, merrors.InvalidArgument("invalid fixed amount")
	}

	return dmath.AddRat(result, fixedRat), nil
}

func applyMin(result *big.Rat, minStr string) (*big.Rat, error) {
	if strings.TrimSpace(minStr) == "" {
		return result, nil
	}

	minRat, err := dmath.RatFromString(minStr)
	if err != nil {
		return nil, merrors.InvalidArgument("invalid minimum amount")
	}

	if dmath.CmpRat(result, minRat) < 0 {
		return new(big.Rat).Set(minRat), nil
	}

	return result, nil
}

func applyMax(result *big.Rat, maxStr string) (*big.Rat, error) {
	if strings.TrimSpace(maxStr) == "" {
		return result, nil
	}

	maxRat, err := dmath.RatFromString(maxStr)
	if err != nil {
		return nil, merrors.InvalidArgument("invalid maximum amount")
	}

	if dmath.CmpRat(result, maxRat) > 0 {
		return new(big.Rat).Set(maxRat), nil
	}

	return result, nil
}

const (
	attrFxBaseCurrency  = "fx_base_currency"
	attrFxQuoteCurrency = "fx_quote_currency"
	attrFxProvider      = "fx_provider"
	attrFxSide          = "fx_side"
	attrFxRateOverride  = "fx_rate"
)

func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
	if intent == nil || c.oracle == nil {
		return nil
	}

	attrs := intent.GetAttributes()

	base := strings.TrimSpace(attrs[attrFxBaseCurrency])

	quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
	if base == "" || quote == "" {
		return nil
	}

	pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
	provider := strings.TrimSpace(attrs[attrFxProvider])

	snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
		Meta:     oracleclient.RequestMeta{},
		Pair:     pair,
		Provider: provider,
	})
	if err != nil {
		c.logger.Warn("Fees: failed to fetch FX context", zap.Error(err))

		return nil
	}

	if snapshot == nil {
		return nil
	}


	rateValue := strings.TrimSpace(attrs[attrFxRateOverride])

	if rateValue == "" {
		rateValue = snapshot.Mid
	}

	if rateValue == "" {
		rateValue = snapshot.Ask
	}

	if rateValue == "" {
		rateValue = snapshot.Bid
	}

	return &feesv1.FXUsed{
		Pair:       pair,
		Side:       parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
		Rate:       &moneyv1.Decimal{Value: rateValue},
		AsofUnixMs: snapshot.AsOf.UnixMilli(),
		Provider:   snapshot.Provider,
		RateRef:    snapshot.RateRef,
		SpreadBps:  &moneyv1.Decimal{Value: snapshot.SpreadBps},
	}
}

func parseFxSide(value string) fxv1.Side {
	switch strings.ToLower(value) {
	case "buy_base", "buy_base_sell_quote", "buy":
		return fxv1.Side_BUY_BASE_SELL_QUOTE
	case "sell_base", "sell_base_buy_quote", "sell":
		return fxv1.Side_SELL_BASE_BUY_QUOTE
	default:
		return fxv1.Side_SIDE_UNSPECIFIED
	}
}

func inferScale(amount string) uint32 {
	value := strings.TrimSpace(amount)
	if value == "" {
		return 0
	}

	if idx := strings.IndexAny(value, "eE"); idx >= 0 {
		value = value[:idx]
	}

	if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
		value = value[1:]
	}

	if _, after, found := strings.Cut(value, "."); found {
		return uint32(len(after)) //nolint:gosec // decimal scale; cannot overflow
	}

	return 0
}

func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
	if rule.Trigger != trigger {
		return false
	}

	if rule.EffectiveFrom.After(bookedAt) {
		return false
	}

	if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
		return false
	}

	return ruleMatchesAttributes(rule, attributes)
}

func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
	if rule.Metadata != nil {
		for _, field := range []string{"scale", "decimals", "precision"} {
			if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
				return parseScale(field, value)
			}
		}
	}

	return fallback, nil
}

func parseScale(field, value string) (uint32, error) {
	clean := strings.TrimSpace(value)
	if clean == "" {
		return 0, merrors.InvalidArgument(field + " is empty")
	}

	parsed, err := strconv.ParseUint(clean, 10, 32)
	if err != nil {
		return 0, merrors.InvalidArgument("invalid " + field + " value")
	}

	return uint32(parsed), nil
}

func validateComputeInputs(plan *model.FeePlan, intent *feesv1.Intent) (*big.Rat, uint32, model.Trigger, error) {
	if plan == nil {
		return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("plan is required")
	}

	if intent == nil {
		return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("intent is required")
	}

	trigger := convertTrigger(intent.GetTrigger())
	if trigger == model.TriggerUnspecified {
		return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("unsupported trigger")
	}

	baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
	if err != nil {
		return nil, 0, trigger, merrors.InvalidArgument("invalid base amount")
	}

	if baseAmount.Sign() < 0 {
		return nil, 0, trigger, merrors.InvalidArgument("base amount cannot be negative")
	}

	return baseAmount, inferScale(intent.GetBaseAmount().GetAmount()), trigger, nil
}

func planIDFrom(plan *model.FeePlan) string {
	if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
		return planRef.Hex()
	}

	return ""
}

func resolvedCurrency(baseCurrency, ruleCurrency string) string {
	if override := strings.TrimSpace(ruleCurrency); override != "" {
		return override
	}

	return baseCurrency
}

func resolvedEntrySide(rule model.FeeRule) accountingv1.EntrySide {
	// Default fees to debit (i.e. charge the customer) when entry side is not specified.
	if side := mapEntrySide(rule.EntrySide); side != accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
		return side
	}

	return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
}

func buildPostingLine(rule model.FeeRule, amount *big.Rat, scale uint32, currency string, entrySide accountingv1.EntrySide, planID string) *feesv1.DerivedPostingLine {
	return &feesv1.DerivedPostingLine{
		LedgerAccountRef: strings.TrimSpace(rule.LedgerAccountRef),
		Money: &moneyv1.Money{
			Amount:   dmath.FormatRat(amount, scale),
			Currency: currency,
		},
		LineType: mapLineType(rule.LineType),
		Side:     entrySide,
		Meta:     buildRuleMeta(rule, planID),
	}
}

func buildAppliedRule(rule model.FeeRule, planID string) *feesv1.AppliedRule {
	return &feesv1.AppliedRule{
		RuleId:      rule.RuleID,
		RuleVersion: planID,
		Formula:     rule.Formula,
		Rounding:    mapRoundingMode(rule.Rounding),
		TaxCode:     metadataValue(rule.Metadata, "tax_code"),
		TaxRate:     metadataValue(rule.Metadata, "tax_rate"),
		Parameters:  cloneStringMap(rule.Metadata),
	}
}

func buildRuleMeta(rule model.FeeRule, planID string) map[string]string {
	meta := map[string]string{"fee_rule_id": rule.RuleID}
	if planID != "" {
		meta["fee_plan_id"] = planID
	}

	if rule.Metadata != nil {
		if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
			meta["tax_code"] = taxCode
		}

		if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
			meta["tax_rate"] = taxRate
		}
	}

	return meta
}

func metadataValue(meta map[string]string, key string) string {
	if meta == nil {
		return ""
	}

	return strings.TrimSpace(meta[key])
}

func cloneStringMap(src map[string]string) map[string]string {
	if len(src) == 0 {
		return nil
	}

	cloned := make(map[string]string, len(src))
	maps.Copy(cloned, src)

	return cloned
}

func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
	if len(rule.AppliesTo) == 0 {
		return true
	}

	for key, value := range rule.AppliesTo {
		if attributes == nil {
			return false
		}

		attrValue, ok := attributes[key]
		if !ok {
			return false
		}

		if !matchesAttributeValue(value, attrValue) {
			return false
		}
	}

	return true
}

func matchesAttributeValue(expected, actual string) bool {
	trimmed := strings.TrimSpace(expected)
	if trimmed == "" {
		return actual == ""
	}

	for value := range strings.SplitSeq(trimmed, ",") {
		value = strings.TrimSpace(value)
		if value == "" {
			continue
		}

		if value == "*" || value == actual {
			return true
		}
	}

	return false
}

func mapLineType(lineType string) accountingv1.PostingLineType {
	switch strings.ToLower(lineType) {
	case "tax":
		return accountingv1.PostingLineType_POSTING_LINE_TAX
	case "spread":
		return accountingv1.PostingLineType_POSTING_LINE_SPREAD
	case "reversal":
		return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
	default:
		return accountingv1.PostingLineType_POSTING_LINE_FEE
	}
}

func mapEntrySide(entrySide string) accountingv1.EntrySide {
	switch strings.ToLower(entrySide) {
	case "debit":
		return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
	case "credit":
		return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
	default:
		return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
	}
}

func toDecimalRounding(mode string) dmath.RoundingMode {
	switch strings.ToLower(strings.TrimSpace(mode)) {
	case "half_up":
		return dmath.RoundingModeHalfUp
	case "down":
		return dmath.RoundingModeDown
	case "half_even", "bankers":
		return dmath.RoundingModeHalfEven
	default:
		return dmath.RoundingModeHalfEven
	}
}

func mapRoundingMode(mode string) moneyv1.RoundingMode {
	switch strings.ToLower(mode) {
	case "half_up":
		return moneyv1.RoundingMode_ROUND_HALF_UP
	case "down":
		return moneyv1.RoundingMode_ROUND_DOWN
	default:
		return moneyv1.RoundingMode_ROUND_HALF_EVEN
	}
}

func convertTrigger(trigger feesv1.Trigger) model.Trigger {
	switch trigger {
	case feesv1.Trigger_TRIGGER_UNSPECIFIED:
		return model.TriggerUnspecified
	case feesv1.Trigger_TRIGGER_CAPTURE:
		return model.TriggerCapture
	case feesv1.Trigger_TRIGGER_REFUND:
		return model.TriggerRefund
	case feesv1.Trigger_TRIGGER_DISPUTE:
		return model.TriggerDispute
	case feesv1.Trigger_TRIGGER_PAYOUT:
		return model.TriggerPayout
	case feesv1.Trigger_TRIGGER_FX_CONVERSION:
		return model.TriggerFXConversion
	default:
		return model.TriggerUnspecified
	}
}
