package resolver

import (
	"context"
	"errors"
	"strings"
	"time"

	"github.com/tech/sendico/billing/fees/storage"
	"github.com/tech/sendico/billing/fees/storage/model"
	"github.com/tech/sendico/pkg/merrors"
	"github.com/tech/sendico/pkg/mlogger"
	"github.com/tech/sendico/pkg/mutil/mzap"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.uber.org/zap"
)

type planFinder interface {
	FindActiveOrgPlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error)
	FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error)
}

type feeResolver struct {
	plans  storage.PlansStore
	finder planFinder
	logger mlogger.Logger
}

func New(plans storage.PlansStore, logger mlogger.Logger) *feeResolver {
	var finder planFinder
	if pf, ok := plans.(planFinder); ok {
		finder = pf
	}

	if logger == nil {
		logger = zap.NewNop()
	}

	return &feeResolver{
		plans:  plans,
		finder: finder,
		logger: logger.Named("resolver"),
	}
}

func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *bson.ObjectID, trigger model.Trigger, asOf time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
	if r.plans == nil {
		return nil, nil, merrors.InvalidArgument("fees: plans store is required")
	}

	// Try org-specific first if provided.
	if isOrgRef(orgRef) {
		plan, rule, err := r.tryOrgRule(ctx, *orgRef, trigger, asOf, attrs)
		if err != nil {
			return nil, nil, err
		}

		if rule != nil {
			return plan, rule, nil
		}
	}

	plan, err := r.getGlobalPlan(ctx, asOf)
	if err != nil {
		if errors.Is(err, storage.ErrFeePlanNotFound) {
			r.logger.Debug("No applicable global fee plan found", zap.String("trigger", string(trigger)),
				zap.Time("booked_at", asOf), zap.Any("attributes", attrs),
			)

			return nil, nil, merrors.NoData("fees: no applicable fee rule found")
		}

		r.logger.Warn("Failed resolving global fee plan", zap.Error(err))

		return nil, nil, err
	}

	rule, err := selectRule(plan, trigger, asOf, attrs)
	if err != nil {
		if !errors.Is(err, ErrNoFeeRuleFound) {
			r.logger.Warn("Failed selecting rule in global plan", zap.Error(err))
		} else {
			globalFields := zapFieldsForPlan(plan)
			globalFields = append([]zap.Field{
				zap.String("trigger", string(trigger)),
				zap.Time("booked_at", asOf),
				zap.Any("attributes", attrs),
			}, globalFields...)
			r.logger.Debug("No matching rule in global plan", globalFields...)
		}

		return nil, nil, err
	}

	r.logSelectedRule(orgRef, trigger, asOf, attrs, rule, plan)

	return plan, rule, nil
}

// tryOrgRule attempts to find a matching rule in the org-specific plan.
// Returns (plan, rule, nil) on success, (nil, nil, nil) to signal fallthrough to global,
// or (nil, nil, err) on a hard error.
func (r *feeResolver) tryOrgRule(ctx context.Context, orgRef bson.ObjectID, trigger model.Trigger, asOf time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
	plan, err := r.getOrgPlan(ctx, orgRef, asOf)
	if err != nil {
		if errors.Is(err, storage.ErrFeePlanNotFound) {
			return nil, nil, nil
		}

		r.logger.Warn("Failed resolving org fee plan", zap.Error(err), mzap.ObjRef("org_ref", orgRef))

		return nil, nil, err
	}

	rule, selErr := selectRule(plan, trigger, asOf, attrs)
	if selErr == nil {
		return plan, rule, nil
	}

	if !errors.Is(selErr, ErrNoFeeRuleFound) {
		r.logger.Warn("Failed selecting rule for org plan", zap.Error(selErr), mzap.ObjRef("org_ref", orgRef))

		return nil, nil, selErr
	}

	orgFields := zapFieldsForPlan(plan)
	orgFields = append([]zap.Field{
		mzap.ObjRef("org_ref", orgRef),
		zap.String("trigger", string(trigger)),
		zap.Time("booked_at", asOf),
		zap.Any("attributes", attrs),
	}, orgFields...)
	r.logger.Debug("No matching rule in org plan; falling back to global", orgFields...)

	return nil, nil, nil
}

func (r *feeResolver) logSelectedRule(orgRef *bson.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string, rule *model.FeeRule, plan *model.FeePlan) {
	fields := []zap.Field{
		zap.String("trigger", string(trigger)),
		zap.Time("booked_at", at),
		zap.Any("attributes", attrs),
		zap.String("rule_id", rule.RuleID),
		zap.Int("rule_priority", rule.Priority),
		zap.Any("rule_applies_to", rule.AppliesTo),
		zap.Time("rule_effective_from", rule.EffectiveFrom),
	}
	if rule.EffectiveTo != nil {
		fields = append(fields, zap.Time("rule_effective_to", *rule.EffectiveTo))
	}

	if isOrgRef(orgRef) {
		fields = append(fields, mzap.ObjRef("org_ref", *orgRef))
	}

	fields = append(fields, zapFieldsForPlan(plan)...)
	r.logger.Debug("Selected fee rule", fields...)
}

func isOrgRef(ref *bson.ObjectID) bool {
	return ref != nil && !ref.IsZero()
}

func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
	if r.finder != nil {
		return r.finder.FindActiveOrgPlan(ctx, orgRef, at)
	}

	return r.plans.GetActivePlan(ctx, orgRef, at)
}

func (r *feeResolver) getGlobalPlan(ctx context.Context, asOf time.Time) (*model.FeePlan, error) {
	if r.finder != nil {
		return r.finder.FindActiveGlobalPlan(ctx, asOf)
	}

	// Treat zero ObjectID as global in legacy path.
	return r.plans.GetActivePlan(ctx, bson.NilObjectID, asOf)
}

func selectRule(plan *model.FeePlan, trigger model.Trigger, asOf time.Time, attrs map[string]string) (*model.FeeRule, error) {
	if plan == nil {
		return nil, merrors.NoData("fees: no applicable fee rule found")
	}

	var (
		selected        *model.FeeRule
		highestPriority int
	)

	for _, rule := range plan.Rules {
		if !ruleIsActive(rule, trigger, asOf) {
			continue
		}

		if !matchesAppliesTo(rule.AppliesTo, attrs) {
			continue
		}

		if selected == nil || rule.Priority > highestPriority {
			matched := rule
			selected = &matched
			highestPriority = rule.Priority

			continue
		}

		if rule.Priority == highestPriority {
			return nil, merrors.DataConflict("fees: conflicting fee rules")
		}
	}

	if selected == nil {
		return nil, merrors.NoData("fees: no applicable fee rule found")
	}

	return selected, nil
}

func ruleIsActive(rule model.FeeRule, trigger model.Trigger, asOf time.Time) bool {
	if rule.Trigger != trigger {
		return false
	}

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

	if rule.EffectiveTo != nil && !rule.EffectiveTo.After(asOf) {
		return false
	}

	return true
}

func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool {
	if len(appliesTo) == 0 {
		return true
	}

	for key, value := range appliesTo {
		if attrs == nil {
			return false
		}

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

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

	return true
}

func matchesAppliesValue(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 zapFieldsForPlan(plan *model.FeePlan) []zap.Field {
	if plan == nil {
		return []zap.Field{zap.Bool("plan_present", false)}
	}

	fields := []zap.Field{
		zap.Bool("plan_present", true),
		zap.Bool("plan_active", plan.Active),
		zap.Time("plan_effective_from", plan.EffectiveFrom),
		zap.Int("plan_rules_count", len(plan.Rules)),
	}
	if plan.EffectiveTo != nil {
		fields = append(fields, zap.Time("plan_effective_to", *plan.EffectiveTo))
	} else {
		fields = append(fields, zap.Bool("plan_effective_to_set", false))
	}

	if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
		fields = append(fields, mzap.ObjRef("plan_org_ref", *plan.OrganizationRef))
	} else {
		fields = append(fields, zap.Bool("plan_org_ref_set", false))
	}

	if plan.GetID() != nil && !plan.GetID().IsZero() {
		fields = append(fields, mzap.StorableRef(plan))
	}

	return fields
}
