package billing

import (
	"context"
	"errors"
	"fmt"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
	"github.com/google/uuid"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

// subscriptionData is the slice of Paddle's subscription payload we read. Fields
// we ignore are simply absent — encoding/json drops them.
type subscriptionData struct {
	ID         string `json:"id"`
	CustomerID string `json:"customer_id"`
	Status     string `json:"status"`
	CustomData struct {
		AccountID  string `json:"account_id"`
		InstanceID string `json:"instance_id"`
	} `json:"custom_data"`
	CurrentBillingPeriod struct {
		EndsAt time.Time `json:"ends_at"`
	} `json:"current_billing_period"`
	Items []struct {
		Price struct {
			ID string `json:"id"`
		} `json:"price"`
		Quantity int `json:"quantity"`
	} `json:"items"`
}

func (d subscriptionData) lineItems() []catalogue.Item {
	items := make([]catalogue.Item, 0, len(d.Items))
	for _, it := range d.Items {
		items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity})
	}
	return items
}

// handleSubscription folds created/updated/activated into one job: make the
// world match the subscription's CURRENT state. That is what keeps out-of-order
// delivery correct — an updated arriving before its created still carries the
// full item list, so reading all of it is reading current state, not a
// transition.
func handleSubscription(ctx context.Context, ev Event) error {
	d, err := decode[subscriptionData](ev)
	if err != nil {
		return err
	}
	if d.CustomData.InstanceID == "" {
		return fmt.Errorf("subscription %s has no instance_id in custom_data", d.ID)
	}

	match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), d.lineItems())
	if err != nil {
		// A price we cannot map is a configuration error, not a customer error.
		// Fail loudly so it is retried and surfaced rather than guessed.
		return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err)
	}

	// Resolve BEFORE recording. A self-hosted subscription's custom_data is
	// written at checkout and names the placeholder; the claim rewrote the
	// instance's identity to the install's real UUID and patched Paddle, but that
	// patch is best-effort and any event already in flight still carries the old
	// id. Writing it straight through would revert the linked subscription row and
	// then fail to find the instance, wedging every renewal.
	instanceID, inst, err := resolveInstance(ctx, d.CustomData.InstanceID)
	if err != nil {
		return fmt.Errorf("subscription %s names unknown instance %s: %w",
			d.ID, d.CustomData.InstanceID, err)
	}

	sub := models.Subscription{
		AccountID:            d.CustomData.AccountID,
		InstanceID:           instanceID,
		PaddleSubscriptionID: d.ID,
		Tier:                 match.Tier,
		Term:                 match.Term,
		Status:               d.Status,
		CurrentPeriodEnd:     d.CurrentBillingPeriod.EndsAt,
		Items:                toSubItems(d.lineItems()),
	}
	if err := upsertSubscription(ctx, sub); err != nil {
		return err
	}

	// Learn the Paddle customer ID onto the account the first time we see it.
	if d.CustomerID != "" && d.CustomData.AccountID != "" {
		_, _ = db.Admin("accounts").UpdateOne(ctx,
			bson.M{"account_id": d.CustomData.AccountID, "paddle_customer_id": bson.M{"$in": bson.A{nil, ""}}},
			bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}})
	}

	// Placeholders are the payment-first path: the instance does not exist until
	// this confirmed-payment event. A cloud placeholder is provisioned here and
	// then issued (first term). A self-hosted placeholder has no UUID to bind to
	// until the customer pastes their install's — its subscription is recorded and
	// the link endpoint issues later.
	reason := models.ReasonEntitlementChange
	if inst.Placeholder {
		if inst.Deployment != license.DeploymentCloud {
			return nil
		}
		provisioned, err := completeCloudPlaceholder(ctx, &inst)
		if err != nil {
			return err
		}
		inst = *provisioned
		reason = models.ReasonNew
	}

	return promoteAndIssue(ctx, &inst, match, reason)
}

// resolveInstance finds the instance a webhook's custom_data names, following the
// identity trail when the id is one a placeholder claim or a relink has since
// replaced. It returns the instance's CURRENT id, which is the only id anything
// else should be written against.
func resolveInstance(ctx context.Context, customDataID string) (string, models.Instance, error) {
	var inst models.Instance
	err := db.Admin("admin_instances").FindOne(ctx,
		bson.M{"instance_id": customDataID}).Decode(&inst)
	if err == nil {
		return inst.InstanceID, inst, nil
	}
	if !errors.Is(err, mongo.ErrNoDocuments) {
		return "", inst, err
	}
	if err := db.Admin("admin_instances").FindOne(ctx,
		bson.M{"previous_instance_ids": customDataID}).Decode(&inst); err != nil {
		return "", inst, err
	}
	return inst.InstanceID, inst, nil
}

// promoteAndIssue promotes desired→granted from the resolved match, then signs a
// licence from granted. This is the only promotion path other than the staff
// grant, and it exists because a webhook is a confirmed payment.
func promoteAndIssue(ctx context.Context, inst *models.Instance, match catalogue.Match, reason string) error {
	plan, err := models.GetPlan(ctx, inst.Deployment, match.Tier)
	if err != nil {
		return fmt.Errorf("no plan for %s/%s: %w", inst.Deployment, match.Tier, err)
	}
	granted := models.Config{
		Servers:  match.Servers,
		Features: models.Features(match.Features).OrEmpty(),
	}
	limits, _, err := catalogue.Resolve(ctx, plan, granted)
	if err != nil {
		return err
	}
	if err := models.UpsertEntitlement(ctx, models.Entitlement{
		InstanceID:     inst.InstanceID,
		AccountID:      inst.AccountID,
		Deployment:     inst.Deployment,
		Tier:           match.Tier,
		Term:           match.Term,
		Desired:        granted,
		Granted:        granted,
		ResolvedLimits: limits,
	}); err != nil {
		return err
	}

	lic, err := licensing.Issue(ctx, licensing.IssueInput{
		InstanceID: inst.InstanceID,
		Tier:       match.Tier,
		Term:       match.Term,
		Reason:     reason,
		IssuedBy:   "paddle",
	})
	if err != nil {
		return fmt.Errorf("issue for %s: %w", inst.InstanceID, err)
	}
	deliver(ctx, inst, lic, billingEmailFor(ctx, inst.AccountID))
	return nil
}

// handleCanceled marks the SUBSCRIPTION cancelled and takes NO licence action.
//
// The instance stays active until its licence expires, when the existing
// lifecycle sweep lapses it. Flipping the instance to cancelled here would stop
// inject.Reconcile and the sweep repairing a licence that is still valid — the
// opposite of "keeps working until it expires".
func handleCanceled(ctx context.Context, ev Event) error {
	d, err := decode[subscriptionData](ev)
	if err != nil {
		return err
	}
	if _, err := db.Admin("subscriptions").UpdateOne(ctx,
		bson.M{"paddle_subscription_id": d.ID},
		bson.M{"$set": bson.M{"status": models.SubCanceled}}); err != nil {
		return err
	}
	if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
		_ = mail.Default.SendCancelled(to, instanceNameFor(ctx, d.CustomData.InstanceID))
	}
	return nil
}

// handlePastDue flags the subscription and notifies, but leaves the licence
// alone. Dunning is Paddle's; ours is not to punish a retryable card failure.
func handlePastDue(ctx context.Context, ev Event) error {
	d, err := decode[subscriptionData](ev)
	if err != nil {
		return err
	}
	if _, err := db.Admin("subscriptions").UpdateOne(ctx,
		bson.M{"paddle_subscription_id": d.ID},
		bson.M{"$set": bson.M{"status": models.SubPastDue}}); err != nil {
		return err
	}
	if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
		_ = mail.Default.SendPastDue(to, instanceNameFor(ctx, d.CustomData.InstanceID))
	}
	return nil
}

// handleCustomerUpdated syncs the billing email onto the account.
func handleCustomerUpdated(ctx context.Context, ev Event) error {
	d, err := decode[struct {
		ID    string `json:"id"`
		Email string `json:"email"`
	}](ev)
	if err != nil {
		return err
	}
	if d.ID == "" || d.Email == "" {
		return nil
	}
	_, err = db.Admin("accounts").UpdateOne(ctx,
		bson.M{"paddle_customer_id": d.ID},
		bson.M{"$set": bson.M{"billing_email": d.Email}})
	return err
}

// IssueForInstance issues from an instance's recorded subscription. Called when
// a self-hosted customer finally links a placeholder they have already paid for.
func IssueForInstance(ctx context.Context, instanceID string) error {
	var sub models.Subscription
	if err := db.Admin("subscriptions").FindOne(ctx,
		bson.M{"instance_id": instanceID, "status": models.SubActive}).Decode(&sub); err != nil {
		return fmt.Errorf("no active subscription for %s: %w", instanceID, err)
	}
	var inst models.Instance
	if err := db.Admin("admin_instances").FindOne(ctx,
		bson.M{"instance_id": instanceID}).Decode(&inst); err != nil {
		return err
	}
	items := make([]catalogue.Item, 0, len(sub.Items))
	for _, it := range sub.Items {
		items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
	}
	match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
	if err != nil {
		return err
	}
	return promoteAndIssue(ctx, &inst, match, models.ReasonNew)
}

func toSubItems(items []catalogue.Item) []models.SubItem {
	out := make([]models.SubItem, 0, len(items))
	for _, it := range items {
		out = append(out, models.SubItem{PriceID: it.PriceID, Quantity: it.Quantity})
	}
	return out
}

func upsertSubscription(ctx context.Context, sub models.Subscription) error {
	_, err := db.Admin("subscriptions").UpdateOne(ctx,
		bson.M{"paddle_subscription_id": sub.PaddleSubscriptionID},
		bson.M{"$set": bson.M{
			"account_id":         sub.AccountID,
			"instance_id":        sub.InstanceID,
			"tier":               sub.Tier,
			"term":               sub.Term,
			"status":             sub.Status,
			"current_period_end": sub.CurrentPeriodEnd,
			"items":              sub.Items,
		}, "$setOnInsert": bson.M{
			"subscription_id":        uuid.NewString(),
			"paddle_subscription_id": sub.PaddleSubscriptionID,
		}},
		options.UpdateOne().SetUpsert(true))
	return err
}

// billingEmailFor reads the account's billing email for self-hosted delivery.
func billingEmailFor(ctx context.Context, accountID string) string {
	var acc models.Account
	if err := db.Admin("accounts").FindOne(ctx,
		bson.M{"account_id": accountID}).Decode(&acc); err != nil {
		return ""
	}
	return acc.BillingEmail
}

// instanceNameFor is a best-effort display name for an email subject.
func instanceNameFor(ctx context.Context, instanceID string) string {
	// Alias-aware: a cancellation can name a placeholder id, and "your instance"
	// in place of the name the customer chose reads like the wrong email.
	_, inst, err := resolveInstance(ctx, instanceID)
	if err != nil || inst.Name == "" {
		return "your instance"
	}
	return inst.Name
}
