package payment

import (
	"errors"
	"fmt"
	"time"

	"github.com/pingcap/dbaas/billing2/billing_errors"
	"github.com/pingcap/dbaas/billing2/db"
	"github.com/pingcap/dbaas/billing2/db/entity"
	"github.com/pingcap/dbaas/billing2/forms"
	"gorm.io/gorm"
)

// validChargeModes is the allow-list of charge_mode values accepted by the
// SENT transition. The set mirrors the InvoiceChargeMode constants in
// github.com/pingcap/dbaas/billing2/handler/paymenthandler (ChargeModeAuto /
// ChargeModeManual) plus a `recurring` slot for the upcoming subscription
// flow that is not yet exported as a constant there. Defining the allow-list
// locally keeps the validation independent of the handler package and avoids
// a circular dependency (model/payment -> handler/paymenthandler).
var validChargeModes = map[string]bool{
	"auto":      true,
	"manual":    true,
	"recurring": true,
}

// ListStripeEligibleInvoices scans billing2_invoices for the paying worker.
// Eligibility matches §Scan Range in the design doc:
//   - status = 'generated'
//   - the invoice's billing_config has at least one active stripe payment method
//   - invoice_date in [start, end)
//
// The payment-method check is a JOIN against payment_methods rather than a
// filter on invoice.payment_type: invoice rows are created with payment_type
// unset (only the regular Pay workflow populates it on the SENT transition),
// so filtering on the invoice column would silently drop every generated row.
// payment_methods is the authoritative source for "this billing_config pays
// with stripe", so we drive the join from there.
//
// The `tenant.kind != 'internal'` filter is NOT applied here: tenant kind is
// checked by the caller via centralhandler.GetTenant.
//
// Each row is materialized via invoiceFromEntityWithCosts (not the
// bare invoiceFromEntity). The Stripe handler iterates
// invoice.CategoryUnitsCosts to build line items per category — if that
// map is empty, every Stripe line item gets a 0 amount and the
// resulting invoice shows $0 on the dashboard regardless of the local
// cost_price column. The full cost path here is the only way to get
// the per-category breakdown the handler needs.
func (i *InvoiceModel) ListStripeEligibleInvoices(start, end time.Time) ([]forms.Invoice, error) {
	var invoices []entity.Invoice
	err := db.GetDB().
		Joins("INNER JOIN payment_methods pm ON pm.binded_billing_config_id = billing2_invoices.binded_billing_config_id").
		Where("pm.deleted_at IS NULL").
		Where("pm.status = ?", entity.PaymentMethodStatus_Active).
		Where("pm.payment_type = ?", entity.PaymentType_STRIPE).
		Where("billing2_invoices.deleted_at IS NULL").
		Where("billing2_invoices.status = ?", entity.InvoiceStatus_GENERATED).
		Where("billing2_invoices.binded_billing_config_id > 0").
		Where("billing2_invoices.invoice_date >= ? AND billing2_invoices.invoice_date < ?", start, end).
		Order("billing2_invoices.invoice_date ASC").
		Find(&invoices).Error
	if err != nil {
		return nil, err
	}
	out := make([]forms.Invoice, 0, len(invoices))
	for idx := range invoices {
		inv, err := invoiceFromEntityWithCosts(&invoices[idx])
		if err != nil {
			return nil, err
		}
		out = append(out, *inv)
	}
	return out, nil
}

// ListCarryoverInvoices returns below_threshold_carry invoices for a
// tenant/billing_config pair, oldest first. These are the invoices
// awaiting the next merged push.
//
// Since the status-based carryover model (fix/stripe-paying-runner-bugs),
// billing2_invoices.status is the single source of truth for carryover:
// the stripe_invoice_push_carryover pool table is no longer consulted by
// the paying worker. This query replaces the old pool read
// (CarryoverModel.GetOrCreate + source_invoice_ids decode +
// ListInvoicesByIDs fetch) with one direct scan.
func (i *InvoiceModel) ListCarryoverInvoices(tenantID, billingConfigID uint) ([]forms.Invoice, error) {
	var entities []entity.Invoice
	if err := db.GetDB().
		Where("tenant_id = ? AND binded_billing_config_id = ?", tenantID, billingConfigID).
		Where("status = ?", entity.InvoiceStatus_BELOW_THRESHOLD_CARRY).
		Where("deleted_at IS NULL").
		Order("invoice_date ASC").
		Find(&entities).Error; err != nil {
		return nil, err
	}
	out := make([]forms.Invoice, 0, len(entities))
	for idx := range entities {
		inv, err := invoiceFromEntityWithCosts(&entities[idx])
		if err != nil {
			return nil, err
		}
		out = append(out, *inv)
	}
	return out, nil
}

// BatchUpdateInvoicesToSENT marks the given invoice IDs as SENT with the
// pushed Stripe invoice ID, charge mode, and the active payment method used
// to settle them. paymentMethod is REQUIRED: this transition is the
// pay-time event (Phase 2 of the invoice/PM decoupling), and without the
// active payment_method_id + payment_type + payment_code the row would have
// to fall back to a JOIN against current payment_methods at filter time,
// which silently drops legacy data on NULL. Refusing to take nil forces
// callers to pass the right pm.
func (i *InvoiceModel) BatchUpdateInvoicesToSENT(
	invoiceIDs []uint,
	stripeInvoiceID string,
	chargeMode string,
	paymentMethod *forms.PaymentMethod,
) error {
	if len(invoiceIDs) == 0 {
		return nil
	}
	if !validChargeModes[chargeMode] {
		return billing_errors.NewInvalidInputError(fmt.Errorf("charge_mode must be one of [auto, manual, recurring], got %q", chargeMode))
	}
	if paymentMethod == nil {
		return billing_errors.NewInvalidInputError(errors.New("paymentMethod is required when transitioning invoices to SENT"))
	}
	return db.GetDB().Transaction(func(tx *gorm.DB) error {
		return i.BatchUpdateInvoicesToSENTWithDB(tx, invoiceIDs, stripeInvoiceID, chargeMode, paymentMethod)
	})
}

// BatchUpdateInvoicesToSENTWithDB accepts an explicit *gorm.DB so the caller
// can wrap this update in an outer transaction (e.g. together with a
// carryover write so the two stay consistent). When called standalone via
// BatchUpdateInvoicesToSENT the public wrapper opens its own transaction.
//
// paymentMethod is REQUIRED and atomically persisted alongside status,
// sent_date, stripe_invoice_id, and charge_mode. This is the pay-time
// write that backs the /invoices query's payment_method_id NOT IN (...)
// filter; without it, sent rows have payment_method_id = NULL and the
// SQL `pm NOT IN (set)` evaluates to NULL/false, silently hiding them
// from the operations console.
func (i *InvoiceModel) BatchUpdateInvoicesToSENTWithDB(
	exec *gorm.DB,
	invoiceIDs []uint,
	stripeInvoiceID string,
	chargeMode string,
	paymentMethod *forms.PaymentMethod,
) error {
	if len(invoiceIDs) == 0 {
		return nil
	}
	if !validChargeModes[chargeMode] {
		return billing_errors.NewInvalidInputError(fmt.Errorf("charge_mode must be one of [auto, manual, recurring], got %q", chargeMode))
	}
	if paymentMethod == nil {
		return billing_errors.NewInvalidInputError(errors.New("paymentMethod is required when transitioning invoices to SENT"))
	}
	now := time.Now()
	paymentCodeCopy := paymentMethod.PaymentCode
	return exec.Model(&entity.Invoice{}).
		// Guard with WHERE status='generated' so a concurrent confirm
		// (which already flipped the row to SENT) or a parallel worker
		// tick cannot double-write. RowsAffected will be fewer than
		// len(invoiceIDs) when some rows were already handled — the
		// caller can inspect if needed, but for the paying worker the
		// carryover clear and heartbeat still work on the same IDs.
		Where("id IN ? AND status IN (?)", invoiceIDs,
			[]string{entity.InvoiceStatus_GENERATED, entity.InvoiceStatus_BELOW_THRESHOLD_CARRY}).
		Updates(map[string]interface{}{
			"status":            entity.InvoiceStatus_SENT,
			"sent_date":         &now,
			"stripe_invoice_id": &stripeInvoiceID,
			"charge_mode":       &chargeMode,
			"payment_method_id": paymentMethod.PaymentMethodID,
			"payment_type":      paymentMethod.PaymentType,
			"payment_code":      &paymentCodeCopy,
		}).Error
}

// BatchUpdateInvoicesToCarry marks the given invoice IDs as
// BELOW_THRESHOLD_CARRY.
//
// Deliberately does NOT write payment_method_id / payment_type — per Phase 1
// of the invoice/PM decoupling, pm_id is pay-time metadata. A carried row
// has not been paid yet, so writing the active PM here would be
// overwriting guesswork the day the customer switches payment_method. Carry
// state stays PM-less until the next month's paying run promotes the row
// to SENT (via BatchUpdateInvoicesToSENT[WithDB], which writes the
// pay-time PM).
func (i *InvoiceModel) BatchUpdateInvoicesToCarry(invoiceIDs []uint) error {
	if len(invoiceIDs) == 0 {
		return nil
	}
	return db.GetDB().Model(&entity.Invoice{}).
		Where("id IN ? AND status IN (?)", invoiceIDs,
			[]string{entity.InvoiceStatus_GENERATED, entity.InvoiceStatus_BELOW_THRESHOLD_CARRY}).
		Update("status", entity.InvoiceStatus_BELOW_THRESHOLD_CARRY).Error
}

// BatchUpdateInvoicesToCarryWithDB accepts an explicit *gorm.DB so the
// caller can wrap this update in an outer transaction together with a
// carryover pool write (UpsertWithAmountWithDB).
func (i *InvoiceModel) BatchUpdateInvoicesToCarryWithDB(exec *gorm.DB, invoiceIDs []uint) error {
	if len(invoiceIDs) == 0 {
		return nil
	}
	return exec.Model(&entity.Invoice{}).
		Where("id IN ? AND status IN (?)", invoiceIDs,
			[]string{entity.InvoiceStatus_GENERATED, entity.InvoiceStatus_BELOW_THRESHOLD_CARRY}).
		Update("status", entity.InvoiceStatus_BELOW_THRESHOLD_CARRY).Error
}

// BatchUpdateInvoicesToSuspended flips the given invoice IDs to SUSPENDED.
// Used by the paying worker for invoices under a managed-by-sales payment
// method whose type is not COMMITMENT — those customers do not auto-pay
// via Stripe (the sales team bills them out-of-band), so the worker parks
// the row in SUSPENDED rather than letting it sit in GENERATED forever or
// landing in the carry pool.
//
// Same Phase 1 contract as BatchUpdateInvoicesToCarry: this is not a pay
// event, so payment_method_id / payment_type are not written.
func (i *InvoiceModel) BatchUpdateInvoicesToSuspended(invoiceIDs []uint) error {
	if len(invoiceIDs) == 0 {
		return nil
	}
	return db.GetDB().Model(&entity.Invoice{}).
		Where("id IN ? AND status IN (?)", invoiceIDs,
			[]string{entity.InvoiceStatus_GENERATED, entity.InvoiceStatus_BELOW_THRESHOLD_CARRY}).
		Update("status", entity.InvoiceStatus_SUSPENDED).Error
}

// BatchUpdateInvoicesToSuspendedWithDB accepts an explicit *gorm.DB so the
// caller can wrap this update in an outer transaction (e.g. together with
// a carryover pool clear so the two writes stay consistent).
func (i *InvoiceModel) BatchUpdateInvoicesToSuspendedWithDB(exec *gorm.DB, invoiceIDs []uint) error {
	if len(invoiceIDs) == 0 {
		return nil
	}
	return exec.Model(&entity.Invoice{}).
		Where("id IN ? AND status IN (?)", invoiceIDs,
			[]string{entity.InvoiceStatus_GENERATED, entity.InvoiceStatus_BELOW_THRESHOLD_CARRY}).
		Update("status", entity.InvoiceStatus_SUSPENDED).Error
}

// BatchUpdateInvoicesToExpired marks the given invoice IDs as EXPIRED_CARRYOVER.
func (i *InvoiceModel) BatchUpdateInvoicesToExpired(invoiceIDs []uint) error {
	if len(invoiceIDs) == 0 {
		return nil
	}
	return db.GetDB().Model(&entity.Invoice{}).
		Where("id IN ? AND status = ?", invoiceIDs, entity.InvoiceStatus_BELOW_THRESHOLD_CARRY).
		Update("status", entity.InvoiceStatus_EXPIRED_CARRYOVER).Error
}

// BatchUpdateInvoicesToExpiredWithDB accepts an explicit *gorm.DB so the
// caller can wrap this update in an outer transaction.
func (i *InvoiceModel) BatchUpdateInvoicesToExpiredWithDB(exec *gorm.DB, invoiceIDs []uint) error {
	if len(invoiceIDs) == 0 {
		return nil
	}
	return exec.Model(&entity.Invoice{}).
		Where("id IN ? AND status = ?", invoiceIDs, entity.InvoiceStatus_BELOW_THRESHOLD_CARRY).
		Update("status", entity.InvoiceStatus_EXPIRED_CARRYOVER).Error
}
