package fees

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

	"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"
)

// Calculator isolates fee rule evaluation logic so it can be reused and tested.
type Calculator interface {
	Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*CalculationResult, error)
}

// CalculationResult contains derived fee lines and audit metadata.
type CalculationResult struct {
	Lines   []*feesv1.DerivedPostingLine
	Applied []*feesv1.AppliedRule
	FxUsed  *feesv1.FXUsed
}

// quoteCalculator is the default Calculator implementation.
type fxOracle interface {
	LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
}

type quoteCalculator struct {
	logger mlogger.Logger
	oracle fxOracle
}

func newQuoteCalculator(logger mlogger.Logger, oracle fxOracle) Calculator {
	return &quoteCalculator{
		logger: logger.Named("calculator"),
		oracle: oracle,
	}
}

func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
	if plan == nil {
		return nil, merrors.InvalidArgument("plan is required")
	}
	if intent == nil {
		return nil, merrors.InvalidArgument("intent is required")
	}

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

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

	baseScale := inferScale(intent.GetBaseAmount().GetAmount())

	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
	})

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

	planID := ""
	if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
		planID = planRef.Hex()
	}

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

		ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
		if ledgerAccountRef == "" {
			c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
			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 := intent.GetBaseAmount().GetCurrency()
		if override := strings.TrimSpace(rule.Currency); override != "" {
			currency = override
		}

		entrySide := mapEntrySide(rule.EntrySide)
		if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
			entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
		}

		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
			}
		}

		lines = append(lines, &feesv1.DerivedPostingLine{
			LedgerAccountRef: ledgerAccountRef,
			Money: &moneyv1.Money{
				Amount:   dmath.FormatRat(amount, scale),
				Currency: currency,
			},
			LineType: mapLineType(rule.LineType),
			Side:     entrySide,
			Meta:     meta,
		})

		applied = append(applied, &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),
		})
	}

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

	return &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)

	if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
		percentageRat, perr := dmath.RatFromString(percentage)
		if perr != nil {
			return nil, 0, merrors.InvalidArgument("invalid percentage")
		}
		result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
	}

	if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
		fixedRat, ferr := dmath.RatFromString(fixed)
		if ferr != nil {
			return nil, 0, merrors.InvalidArgument("invalid fixed amount")
		}
		result = dmath.AddRat(result, fixedRat)
	}

	if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
		minRat, merr := dmath.RatFromString(minStr)
		if merr != nil {
			return nil, 0, merrors.InvalidArgument("invalid minimum amount")
		}
		if dmath.CmpRat(result, minRat) < 0 {
			result = new(big.Rat).Set(minRat)
		}
	}

	if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
		maxRat, merr := dmath.RatFromString(maxStr)
		if merr != nil {
			return nil, 0, merrors.InvalidArgument("invalid maximum amount")
		}
		if dmath.CmpRat(result, maxRat) > 0 {
			result = new(big.Rat).Set(maxRat)
		}
	}

	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
}

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 dot := strings.IndexByte(value, '.'); dot >= 0 {
		return uint32(len(value[dot+1:]))
	}
	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 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))
	for k, v := range src {
		cloned[k] = v
	}
	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
		}
		if attrValue, ok := attributes[key]; !ok || attrValue != value {
			return false
		}
	}
	return true
}

func convertTrigger(trigger feesv1.Trigger) model.Trigger {
	switch trigger {
	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
	}
}

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
	}
}
