package billing

import (
	"context"
	"errors"
	"sync"
	"testing"

	"github.com/stretchr/testify/require"
)

type fakeProvider struct {
	mu           sync.Mutex
	creates      int
	lastCheckout CheckoutParams
	event        *Event
	checkout     ProviderCheckout
	subscription ProviderSubscription
}

func (p *fakeProvider) CreateCheckout(_ context.Context, input CheckoutParams) (*ProviderCheckout, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.creates++
	p.lastCheckout = input
	out := p.checkout
	out.PriceID = input.PriceID
	return &out, nil
}
func (p *fakeProvider) GetCheckout(context.Context, string) (*ProviderCheckout, error) {
	out := p.checkout
	return &out, nil
}
func (p *fakeProvider) GetSubscription(context.Context, string) (*ProviderSubscription, error) {
	out := p.subscription
	return &out, nil
}
func (p *fakeProvider) CreatePortal(context.Context, string, string) (string, error) {
	return "https://portal.test", nil
}
func (p *fakeProvider) VerifyEvent([]byte, string, string) (*Event, error) {
	if p.event == nil {
		return nil, ErrInvalidSignature
	}
	out := *p.event
	return &out, nil
}

type fakeRepo struct {
	mu             sync.Mutex
	attempts       map[string]*CheckoutAttempt
	requests       map[string]string
	processed      map[string]bool
	state          SubscriptionState
	lastCreated    int64
	lastEvent      string
	purchases      map[string]*PurchaseAudit
	entitlementErr error
	applyCalls     int
}

func (r *fakeRepo) applyCallCount() int {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.applyCalls
}

func newFakeRepo() *fakeRepo {
	return &fakeRepo{attempts: map[string]*CheckoutAttempt{}, requests: map[string]string{}, processed: map[string]bool{}, purchases: map[string]*PurchaseAudit{}, state: SubscriptionState{PlanID: PlanFree}}
}
func (r *fakeRepo) InsertCheckout(_ context.Context, a *CheckoutAttempt) (bool, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	key := formatUID(a.UID) + ":" + a.RequestID
	if _, ok := r.requests[key]; ok {
		return false, nil
	}
	cp := *a
	r.attempts[a.ID] = &cp
	r.requests[key] = a.ID
	return true, nil
}
func (r *fakeRepo) GetCheckoutByRequest(_ context.Context, uid int64, request string) (*CheckoutAttempt, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.copyAttempt(r.requests[formatUID(uid)+":"+request])
}
func (r *fakeRepo) GetCheckout(_ context.Context, uid int64, id string) (*CheckoutAttempt, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	a, err := r.copyAttempt(id)
	if err != nil || a.UID != uid {
		return nil, ErrNotFound
	}
	return a, nil
}
func (r *fakeRepo) GetCheckoutBySession(_ context.Context, session string) (*CheckoutAttempt, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	for _, a := range r.attempts {
		if a.SessionID == session {
			cp := *a
			return &cp, nil
		}
	}
	return nil, ErrNotFound
}
func (r *fakeRepo) copyAttempt(id string) (*CheckoutAttempt, error) {
	a := r.attempts[id]
	if a == nil {
		return nil, ErrNotFound
	}
	cp := *a
	return &cp, nil
}
func (r *fakeRepo) UpdateCheckout(_ context.Context, a *CheckoutAttempt) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	cp := *a
	r.attempts[a.ID] = &cp
	return nil
}
func (r *fakeRepo) GetSubscription(context.Context, int64) (*SubscriptionState, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	cp := r.state
	return &cp, nil
}
func (r *fakeRepo) FindUIDByCustomer(_ context.Context, customer string) (int64, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.state.CustomerID == customer {
		return r.state.UID, nil
	}
	for _, a := range r.attempts {
		if a.CustomerID == customer {
			return a.UID, nil
		}
	}
	return 0, ErrNotFound
}
func (r *fakeRepo) ApplyEntitlement(_ context.Context, u EntitlementUpdate) (bool, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.applyCalls++
	if r.entitlementErr != nil {
		return false, r.entitlementErr
	}
	if u.EventCreatedAt < r.lastCreated || (u.EventCreatedAt == r.lastCreated && u.EventID <= r.lastEvent) {
		return false, nil
	}
	r.lastCreated, r.lastEvent = u.EventCreatedAt, u.EventID
	r.state.UID, r.state.Status, r.state.CustomerID, r.state.SubscriptionID = u.UID, u.Status, u.CustomerID, u.SubscriptionID
	r.state.PaidPlanID = u.PaidPlanID
	r.state.CurrentPeriodStart, r.state.CurrentPeriodEnd, r.state.CancelAtPeriodEnd = u.CurrentPeriodStart, u.CurrentPeriodEnd, u.CancelAtPeriodEnd
	if !r.state.AdminOverride {
		r.state.PlanID = u.PlanID
	}
	return true, nil
}
func (r *fakeRepo) ClaimWebhook(_ context.Context, id, _ string, _ int64) (bool, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.processed[id] {
		return false, nil
	}
	r.processed[id] = true
	return true, nil
}
func (r *fakeRepo) ReleaseWebhookClaim(_ context.Context, id string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	delete(r.processed, id)
	return nil
}
func (r *fakeRepo) UpsertPurchase(_ context.Context, p *PurchaseAudit) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	cp := *p
	r.purchases[p.StripeEventID] = &cp
	return nil
}

func newService(repo Repository, provider Provider) *Service {
	return &Service{Repo: repo, Provider: provider, Enabled: true, WebhookSecret: "whsec_test", StarterPriceID: "price_starter", ProPriceID: "price_pro", SuccessURL: "https://app/success", CancelURL: "https://app/cancel", PortalReturnURL: "https://app/billing"}
}

func TestPlanPriceMapping(t *testing.T) {
	s := newService(newFakeRepo(), &fakeProvider{})
	price, err := s.PriceForPlan(" STARTER ")
	require.NoError(t, err)
	require.Equal(t, "price_starter", price)
	plan, err := s.PlanForPrice("price_pro")
	require.NoError(t, err)
	require.Equal(t, PlanPro, plan)
	_, err = s.PriceForPlan(PlanFree)
	require.ErrorIs(t, err, ErrInvalidPlan)
	_, err = s.PlanForPrice("price_spoofed")
	require.ErrorIs(t, err, ErrInvalidPlan)
}

func TestCreateCheckoutIsIdempotent(t *testing.T) {
	r := newFakeRepo()
	p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", URL: "https://checkout", ExpiresAt: 123}}
	s := newService(r, p)
	a, err := s.CreateCheckout(context.Background(), 7, PlanStarter, "request-1")
	require.NoError(t, err)
	b, err := s.CreateCheckout(context.Background(), 7, PlanStarter, "request-1")
	require.NoError(t, err)
	require.Equal(t, a.ID, b.ID)
	require.Equal(t, 1, p.creates)
	_, err = s.CreateCheckout(context.Background(), 7, PlanPro, "request-1")
	require.ErrorIs(t, err, ErrConflict)
}

func TestCreateCheckoutInjectsReturnURLTokens(t *testing.T) {
	r := newFakeRepo()
	p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", URL: "https://checkout"}}
	s := newService(r, p)
	s.SuccessURL = "https://app.test/app/usage/checkout?result=success&checkout_id={CHECKOUT_ID}&plan={PLAN_ID}"
	s.CancelURL = "https://app.test/app/usage/checkout?result=cancel&plan={PLAN_ID}"
	a, err := s.CreateCheckout(context.Background(), 7, PlanStarter, "request with spaces")
	require.NoError(t, err)
	require.Contains(t, p.lastCheckout.SuccessURL, "checkout_id="+a.ID)
	require.Contains(t, p.lastCheckout.SuccessURL, "plan=starter")
	require.NotContains(t, p.lastCheckout.SuccessURL, "{")
	require.Equal(t, "https://app.test/app/usage/checkout?result=cancel&plan=starter", p.lastCheckout.CancelURL)
}

func TestSubscriptionRemainsReadableWhenStripeDisabled(t *testing.T) {
	r := newFakeRepo()
	r.state = SubscriptionState{UID: 7, PlanID: PlanFree}
	s := newService(r, &fakeProvider{})
	s.Enabled = false
	state, err := s.Subscription(context.Background(), 7)
	require.NoError(t, err)
	require.Equal(t, PlanFree, state.PlanID)
}

// Stripe can deliver the same event concurrently. The claim has to be atomic, otherwise both
// deliveries see "not processed yet" and fulfil the event twice.
func TestWebhookConcurrentDeliveriesProcessEventOnce(t *testing.T) {
	r := newFakeRepo()
	r.state = SubscriptionState{UID: 7, PlanID: PlanFree, CustomerID: "cus_1"}
	p := &fakeProvider{event: &Event{
		ID: "evt_concurrent", Type: "customer.subscription.updated", Created: 20,
		ObjectID: "sub_1", CustomerID: "cus_1", PriceID: "price_pro", Status: "active",
	}}
	s := newService(r, p)

	const deliveries = 8
	var wg sync.WaitGroup
	errs := make(chan error, deliveries)
	for i := 0; i < deliveries; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			errs <- s.HandleWebhook(context.Background(), []byte("payload"), "sig")
		}()
	}
	wg.Wait()
	close(errs)
	for err := range errs {
		require.NoError(t, err)
	}
	require.Equal(t, 1, r.applyCallCount(), "concurrent duplicates must fulfil the event exactly once")
	require.Equal(t, PlanPro, r.state.PlanID)
	require.Len(t, r.purchases, 1)
}

// Claiming up front must not swallow the event when processing fails, or a transient outage
// would permanently lose a payment.
func TestWebhookFailedProcessingIsRetryable(t *testing.T) {
	r := newFakeRepo()
	r.state = SubscriptionState{UID: 7, PlanID: PlanFree, CustomerID: "cus_1"}
	r.entitlementErr = errors.New("mongo unavailable")
	p := &fakeProvider{event: &Event{
		ID: "evt_retry", Type: "customer.subscription.updated", Created: 20,
		ObjectID: "sub_1", CustomerID: "cus_1", PriceID: "price_pro", Status: "active",
	}}
	s := newService(r, p)

	require.Error(t, s.HandleWebhook(context.Background(), []byte("payload"), "sig"))
	require.Equal(t, PlanFree, r.state.PlanID)

	// Stripe retries once the outage clears; the claim must have been released.
	r.entitlementErr = nil
	require.NoError(t, s.HandleWebhook(context.Background(), []byte("payload"), "sig"))
	require.Equal(t, PlanPro, r.state.PlanID)
}

func TestWebhookDuplicateOutOfOrderDowngradeAndAdminOverride(t *testing.T) {
	r := newFakeRepo()
	r.state = SubscriptionState{UID: 7, PlanID: PlanFree, CustomerID: "cus_1"}
	p := &fakeProvider{}
	s := newService(r, p)
	p.event = &Event{ID: "evt_new", Type: "customer.subscription.updated", Created: 20, ObjectID: "sub_1", CustomerID: "cus_1", PriceID: "price_pro", Status: "active"}
	require.NoError(t, s.HandleWebhook(context.Background(), []byte("new"), "sig"))
	require.Equal(t, PlanPro, r.state.PlanID)
	require.NoError(t, s.HandleWebhook(context.Background(), []byte("duplicate"), "sig"))
	require.Len(t, r.purchases, 1)
	p.event = &Event{ID: "evt_old", Type: "customer.subscription.deleted", Created: 10, ObjectID: "sub_1", CustomerID: "cus_1", Status: "canceled"}
	require.NoError(t, s.HandleWebhook(context.Background(), []byte("old"), "sig"))
	require.Equal(t, PlanPro, r.state.PlanID)
	p.event = &Event{ID: "evt_delete", Type: "customer.subscription.deleted", Created: 30, ObjectID: "sub_1", CustomerID: "cus_1", Status: "canceled"}
	require.NoError(t, s.HandleWebhook(context.Background(), []byte("delete"), "sig"))
	require.Equal(t, PlanFree, r.state.PlanID)
	r.state.AdminOverride, r.state.PlanID = true, PlanStarter
	p.event = &Event{ID: "evt_admin", Type: "customer.subscription.updated", Created: 40, ObjectID: "sub_1", CustomerID: "cus_1", PriceID: "price_pro", Status: "active"}
	require.NoError(t, s.HandleWebhook(context.Background(), []byte("admin"), "sig"))
	require.Equal(t, PlanStarter, r.state.PlanID)
	require.Equal(t, "active", r.state.Status)
}

func TestPaidCheckoutActivationUsesRetrievedPrice(t *testing.T) {
	r := newFakeRepo()
	a := &CheckoutAttempt{ID: "attempt_1", UID: 9, RequestID: "r1", PlanID: PlanStarter, PriceID: "price_starter", SessionID: "cs_1", FulfillmentStatus: "pending"}
	r.attempts[a.ID] = a
	p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", PaymentStatus: "paid", Status: "complete", PriceID: "price_pro", CustomerID: "cus_9", SubscriptionID: "sub_9"}, subscription: ProviderSubscription{ID: "sub_9", CustomerID: "cus_9", Status: "active", PriceID: "price_pro", CurrentPeriodStart: 10, CurrentPeriodEnd: 20}}
	s := newService(r, p)
	p.event = &Event{ID: "evt_checkout", Type: "checkout.session.completed", Created: 30, ObjectID: "cs_1"}
	require.NoError(t, s.HandleWebhook(context.Background(), []byte("paid"), "sig"))
	require.Equal(t, PlanPro, r.state.PlanID)
	require.Equal(t, "fulfilled", r.attempts[a.ID].FulfillmentStatus)
}

func TestPaidCheckoutIsNotFulfilledWhenEntitlementWriteFails(t *testing.T) {
	r := newFakeRepo()
	r.entitlementErr = errors.New("entitlement unavailable")
	a := &CheckoutAttempt{ID: "attempt_1", UID: 9, SessionID: "cs_1", FulfillmentStatus: "pending"}
	r.attempts[a.ID] = a
	p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", PaymentStatus: "paid", Status: "complete", PriceID: "price_pro", CustomerID: "cus_9", SubscriptionID: "sub_9"}, subscription: ProviderSubscription{ID: "sub_9", CustomerID: "cus_9", Status: "active", PriceID: "price_pro"}}
	s := newService(r, p)
	p.event = &Event{ID: "evt_checkout", Type: "checkout.session.completed", Created: 30, ObjectID: "cs_1"}

	err := s.HandleWebhook(context.Background(), []byte("paid"), "sig")
	require.ErrorContains(t, err, "entitlement unavailable")
	require.Equal(t, "pending", r.attempts[a.ID].FulfillmentStatus)
	require.False(t, r.processed["evt_checkout"])
}

func TestUnpaidCompletedCheckoutWaitsForAsyncSuccess(t *testing.T) {
	r := newFakeRepo()
	a := &CheckoutAttempt{ID: "attempt_1", UID: 9, SessionID: "cs_1", FulfillmentStatus: "pending"}
	r.attempts[a.ID] = a
	p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", PaymentStatus: "unpaid", Status: "complete", PriceID: "price_pro", CustomerID: "cus_9", SubscriptionID: "sub_9"}}
	s := newService(r, p)
	p.event = &Event{ID: "evt_unpaid", Type: "checkout.session.completed", Created: 30, ObjectID: "cs_1"}
	require.NoError(t, s.HandleWebhook(context.Background(), []byte("unpaid"), "sig"))
	require.Equal(t, PlanFree, r.state.PlanID)
	require.Empty(t, r.purchases)
}

func TestCheckoutFailureAndInvoicePaid(t *testing.T) {
	r := newFakeRepo()
	a := &CheckoutAttempt{ID: "attempt_1", UID: 9, SessionID: "cs_1", FulfillmentStatus: "pending"}
	r.attempts[a.ID] = a
	r.state = SubscriptionState{UID: 9, PlanID: PlanStarter, CustomerID: "cus_9", SubscriptionID: "sub_9"}
	p := &fakeProvider{subscription: ProviderSubscription{ID: "sub_9", CustomerID: "cus_9", Status: "past_due", PriceID: "price_pro", CurrentPeriodStart: 10, CurrentPeriodEnd: 20}}
	s := newService(r, p)
	p.event = &Event{ID: "evt_failed", Type: "checkout.session.async_payment_failed", Created: 10, ObjectID: "cs_1"}
	require.NoError(t, s.HandleWebhook(context.Background(), []byte("failed"), "sig"))
	require.Equal(t, "failed", r.attempts[a.ID].FulfillmentStatus)

	// The provider's current subscription price is authoritative, not an invoice metadata/line hint.
	p.event = &Event{ID: "evt_invoice", Type: "invoice.paid", Created: 20, ObjectID: "in_1", SubscriptionID: "sub_9", CustomerID: "cus_9", PriceID: "price_starter"}
	require.NoError(t, s.HandleWebhook(context.Background(), []byte("invoice"), "sig"))
	require.Equal(t, PlanPro, r.state.PlanID)
	require.Equal(t, "active", r.state.Status)
}

func TestDisabled(t *testing.T) {
	_, err := (&Service{}).CreateCheckout(context.Background(), 1, PlanStarter, "x")
	require.True(t, errors.Is(err, ErrDisabled))
}
