package billing

import (
	"context"
	"fmt"
	"net/url"
	"strconv"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/zeromicro/go-zero/core/logx"
)

type Service struct {
	Repo            Repository
	Provider        Provider
	Enabled         bool
	WebhookSecret   string
	StarterPriceID  string
	ProPriceID      string
	SuccessURL      string
	CancelURL       string
	PortalReturnURL string
	Now             func() time.Time
}

func (s *Service) available() error {
	if s == nil || !s.Enabled || s.Repo == nil || s.Provider == nil || strings.TrimSpace(s.StarterPriceID) == "" || strings.TrimSpace(s.ProPriceID) == "" || strings.TrimSpace(s.SuccessURL) == "" || strings.TrimSpace(s.CancelURL) == "" || strings.TrimSpace(s.PortalReturnURL) == "" {
		return ErrDisabled
	}
	return nil
}

func (s *Service) PriceForPlan(plan string) (string, error) {
	switch strings.ToLower(strings.TrimSpace(plan)) {
	case PlanStarter:
		return s.StarterPriceID, nil
	case PlanPro:
		return s.ProPriceID, nil
	default:
		return "", ErrInvalidPlan
	}
}

func (s *Service) PlanForPrice(price string) (string, error) {
	switch price {
	case s.StarterPriceID:
		return PlanStarter, nil
	case s.ProPriceID:
		return PlanPro, nil
	default:
		return "", ErrInvalidPlan
	}
}

func (s *Service) CreateCheckout(ctx context.Context, uid int64, planID, requestID string) (*CheckoutAttempt, error) {
	if err := s.available(); err != nil {
		return nil, err
	}
	requestID = strings.TrimSpace(requestID)
	if uid <= 0 || requestID == "" || len(requestID) > 128 {
		return nil, ErrInvalidRequest
	}
	priceID, err := s.PriceForPlan(planID)
	if err != nil {
		return nil, err
	}
	now := time.Now().UTC()
	if s.Now != nil {
		now = s.Now().UTC()
	}
	a := &CheckoutAttempt{ID: uuid.NewString(), UID: uid, RequestID: requestID, PlanID: strings.ToLower(strings.TrimSpace(planID)), PriceID: priceID, Status: "creating", FulfillmentStatus: "pending", CreatedAt: now.UnixNano(), UpdatedAt: now.UnixNano()}
	inserted, err := s.Repo.InsertCheckout(ctx, a)
	if err != nil {
		return nil, err
	}
	if !inserted {
		existing, err := s.Repo.GetCheckoutByRequest(ctx, uid, requestID)
		if err != nil {
			return nil, err
		}
		if existing.PlanID != a.PlanID {
			return nil, ErrConflict
		}
		if existing.SessionID != "" {
			return existing, nil
		}
		a = existing
	}
	idempotencyKey := fmt.Sprintf("billing-checkout-%d-%s", uid, requestID)
	state, err := s.Repo.GetSubscription(ctx, uid)
	if err != nil {
		return nil, err
	}
	successURL := replaceURLTokens(s.SuccessURL, a.ID, a.PlanID)
	cancelURL := replaceURLTokens(s.CancelURL, a.ID, a.PlanID)
	co, err := s.Provider.CreateCheckout(ctx, CheckoutParams{UID: uid, AttemptID: a.ID, PlanID: a.PlanID, PriceID: priceID, CustomerID: state.CustomerID, SuccessURL: successURL, CancelURL: cancelURL, IdempotencyKey: idempotencyKey})
	if err != nil {
		a.Status, a.Error, a.UpdatedAt = "failed", err.Error(), time.Now().UTC().UnixNano()
		_ = s.Repo.UpdateCheckout(ctx, a)
		return nil, err
	}
	s.applyCheckout(a, co)
	if err := s.Repo.UpdateCheckout(ctx, a); err != nil {
		return nil, err
	}
	return a, nil
}

func (s *Service) GetCheckout(ctx context.Context, uid int64, id string) (*CheckoutAttempt, error) {
	if err := s.available(); err != nil {
		return nil, err
	}
	a, err := s.Repo.GetCheckout(ctx, uid, id)
	if err != nil {
		return nil, err
	}
	if a.SessionID == "" {
		return a, nil
	}
	co, err := s.Provider.GetCheckout(ctx, a.SessionID)
	if err != nil {
		return nil, err
	}
	s.applyCheckout(a, co)
	if co.PriceID != "" {
		plan, mapErr := s.PlanForPrice(co.PriceID)
		if mapErr != nil {
			return nil, mapErr
		}
		a.PlanID = plan
	}
	_ = s.Repo.UpdateCheckout(ctx, a)
	return a, nil
}

func (s *Service) applyCheckout(a *CheckoutAttempt, co *ProviderCheckout) {
	a.SessionID, a.URL, a.ExpiresAt = co.SessionID, co.URL, co.ExpiresAt
	a.Status, a.PaymentStatus = co.Status, co.PaymentStatus
	a.CustomerID, a.SubscriptionID = co.CustomerID, co.SubscriptionID
	if co.PriceID != "" {
		a.PriceID = co.PriceID
	}
	a.UpdatedAt = time.Now().UTC().UnixNano()
}

func (s *Service) Subscription(ctx context.Context, uid int64) (*SubscriptionState, error) {
	if s == nil || s.Repo == nil {
		return nil, ErrDisabled
	}
	return s.Repo.GetSubscription(ctx, uid)
}

func (s *Service) Portal(ctx context.Context, uid int64) (string, error) {
	if err := s.available(); err != nil {
		return "", err
	}
	state, err := s.Repo.GetSubscription(ctx, uid)
	if err != nil {
		return "", err
	}
	if state.CustomerID == "" {
		return "", ErrCustomerRequired
	}
	return s.Provider.CreatePortal(ctx, state.CustomerID, s.PortalReturnURL)
}

func (s *Service) HandleWebhook(ctx context.Context, payload []byte, signature string) error {
	if err := s.available(); err != nil {
		return err
	}
	if strings.TrimSpace(s.WebhookSecret) == "" {
		return ErrDisabled
	}
	e, err := s.Provider.VerifyEvent(payload, signature, s.WebhookSecret)
	if err != nil {
		return err
	}
	// Claim before processing, so two concurrent deliveries of the same event cannot both
	// fulfil it. Processing failures release the claim so Stripe's retry still gets through.
	claimed, err := s.Repo.ClaimWebhook(ctx, e.ID, e.Type, e.Created)
	if err != nil {
		return err
	}
	if !claimed {
		return nil
	}
	if err := s.processEvent(ctx, e); err != nil {
		if rerr := s.Repo.ReleaseWebhookClaim(ctx, e.ID); rerr != nil {
			logx.Errorf("billing release webhook claim %s: %v", e.ID, rerr)
		}
		return err
	}
	return nil
}

func (s *Service) processEvent(ctx context.Context, e *Event) error {
	switch e.Type {
	case "checkout.session.expired", "checkout.session.async_payment_failed":
		a, err := s.Repo.GetCheckoutBySession(ctx, e.ObjectID)
		if err != nil {
			return err
		}
		a.Status = strings.TrimPrefix(e.Type, "checkout.session.")
		a.FulfillmentStatus = "failed"
		a.UpdatedAt = time.Now().UTC().UnixNano()
		return s.Repo.UpdateCheckout(ctx, a)
	case "checkout.session.completed", "checkout.session.async_payment_succeeded":
		return s.fulfillCheckout(ctx, e)
	case "customer.subscription.updated", "customer.subscription.deleted":
		return s.applySubscriptionEvent(ctx, e)
	case "invoice.paid":
		return s.applyInvoiceEvent(ctx, e)
	default:
		return nil
	}
}

func (s *Service) fulfillCheckout(ctx context.Context, e *Event) error {
	a, err := s.Repo.GetCheckoutBySession(ctx, e.ObjectID)
	if err != nil {
		return err
	}
	co, err := s.Provider.GetCheckout(ctx, e.ObjectID)
	if err != nil {
		return err
	}
	plan, err := s.PlanForPrice(co.PriceID)
	if err != nil {
		return err
	}
	if co.PaymentStatus != "paid" && co.PaymentStatus != "no_payment_required" {
		s.applyCheckout(a, co)
		a.FulfillmentStatus = "pending"
		return s.Repo.UpdateCheckout(ctx, a)
	}
	sub, err := s.Provider.GetSubscription(ctx, co.SubscriptionID)
	if err != nil {
		return err
	}
	if sub.PriceID != co.PriceID {
		return ErrInvalidPlan
	}
	s.applyCheckout(a, co)
	if err := s.apply(ctx, e, a.UID, plan, sub, a.SessionID); err != nil {
		return err
	}
	a.PlanID, a.FulfillmentStatus = plan, "fulfilled"
	return s.Repo.UpdateCheckout(ctx, a)
}

func (s *Service) applySubscriptionEvent(ctx context.Context, e *Event) error {
	uid, err := s.Repo.FindUIDByCustomer(ctx, e.CustomerID)
	if err != nil {
		return err
	}
	plan := PlanFree
	if e.Type != "customer.subscription.deleted" {
		plan, err = s.PlanForPrice(e.PriceID)
		if err != nil {
			return err
		}
	} else if e.PriceID != "" {
		if mapped, mapErr := s.PlanForPrice(e.PriceID); mapErr == nil {
			plan = mapped
		}
	} else if state, stateErr := s.Repo.GetSubscription(ctx, uid); stateErr == nil && state.PaidPlanID != "" {
		plan = state.PaidPlanID
	}
	sub := &ProviderSubscription{ID: e.ObjectID, CustomerID: e.CustomerID, Status: e.Status, PriceID: e.PriceID, CurrentPeriodStart: e.PeriodStart, CurrentPeriodEnd: e.PeriodEnd, CancelAtPeriodEnd: e.CancelAtPeriodEnd}
	if e.Type == "customer.subscription.deleted" {
		sub.Status = "canceled"
	}
	return s.apply(ctx, e, uid, plan, sub, "")
}

func (s *Service) applyInvoiceEvent(ctx context.Context, e *Event) error {
	uid, err := s.Repo.FindUIDByCustomer(ctx, e.CustomerID)
	if err != nil {
		return err
	}
	subscriptionID := e.SubscriptionID
	if subscriptionID == "" {
		state, stateErr := s.Repo.GetSubscription(ctx, uid)
		if stateErr != nil {
			return stateErr
		}
		subscriptionID = state.SubscriptionID
	}
	sub, err := s.Provider.GetSubscription(ctx, subscriptionID)
	if err != nil {
		return err
	}
	plan, err := s.PlanForPrice(sub.PriceID)
	if err != nil {
		return err
	}
	if sub.CustomerID == "" {
		sub.CustomerID = e.CustomerID
	}
	sub.Status = "active"
	return s.apply(ctx, e, uid, plan, sub, "")
}

func (s *Service) apply(ctx context.Context, e *Event, uid int64, plan string, sub *ProviderSubscription, sessionID string) error {
	activePlan := plan
	if sub.Status != "active" && sub.Status != "trialing" {
		activePlan = PlanFree
	}
	_, err := s.Repo.ApplyEntitlement(ctx, EntitlementUpdate{EventID: e.ID, EventCreatedAt: e.Created * int64(time.Second), UID: uid, PlanID: activePlan, PaidPlanID: plan, Status: sub.Status, CustomerID: sub.CustomerID, SubscriptionID: sub.ID, CurrentPeriodStart: sub.CurrentPeriodStart * int64(time.Second), CurrentPeriodEnd: sub.CurrentPeriodEnd * int64(time.Second), CancelAtPeriodEnd: sub.CancelAtPeriodEnd})
	if err != nil {
		return err
	}
	// Audit upsert must still run when entitlement was already applied.
	return s.Repo.UpsertPurchase(ctx, &PurchaseAudit{ID: e.ID, UID: uid, PlanID: plan, StripeEventID: e.ID, StripeSessionID: sessionID, StripeCustomerID: sub.CustomerID, StripeSubscriptionID: sub.ID, Status: sub.Status, CreatedAt: e.Created * int64(time.Second)})
}

func ParseUID(v string) (int64, error) { return strconv.ParseInt(v, 10, 64) }

func replaceURLTokens(value, checkoutID, planID string) string {
	value = strings.ReplaceAll(value, "{CHECKOUT_ID}", url.QueryEscape(checkoutID))
	return strings.ReplaceAll(value, "{PLAN_ID}", url.QueryEscape(planID))
}
