from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.models.customer import Customer
from app.models.inventory import StockAdjustment
from app.models.product import Product,ProductUnitConversion,ProductVariant,Unit
from app.models.sale import Sale,SaleItem,SalePayment,SaleIdempotency
from app.models.receivable import Receivable
from app.services.products import inventory_row,require_branch
from app.services.audit import record_audit
METHODS={'CASH','POS','TRANSFER','OTHER'}
def _items(db,business,items):
 rows=[]
 for x in items:
  p=db.get(Product,x.product_id)
  if not p or p.business_id!=business or not p.is_active:raise ValueError('Invalid product.')
  v=None
  if x.variant_id:
   v=db.get(ProductVariant,x.variant_id)
   if not v or v.business_id!=business or v.product_id!=p.id or not v.is_active:raise ValueError('Invalid variant for product.')
  factor=Decimal('1')
  if x.unit_id:
   u=db.get(Unit,x.unit_id)
   if not u or not u.is_active or (u.business_id and u.business_id!=business):raise ValueError('Invalid unit.')
   c=db.scalar(select(ProductUnitConversion).where(ProductUnitConversion.product_id==p.id,ProductUnitConversion.unit_id==x.unit_id))
   if c:factor=c.quantity_in_base
   elif p.base_unit_id!=x.unit_id:raise ValueError('Unit is not valid for this product.')
  line=x.quantity*x.unit_price-x.discount
  if line<0:raise ValueError('Item discount cannot exceed line price.')
  rows.append((x,p,v,x.quantity*factor,line))
 return rows
def create_sale(db,business,user,key,data):
 if not key:raise ValueError('Idempotency-Key header is required.')
 existing=db.scalar(select(SaleIdempotency).where(SaleIdempotency.business_id==business,SaleIdempotency.user_id==user,SaleIdempotency.idempotency_key==key))
 if existing:return db.get(Sale,existing.sale_id),True
 require_branch(db,business,data.branch_id)
 if data.customer_id:
  c=db.get(Customer,data.customer_id)
  if not c or c.business_id!=business or not c.is_active:raise ValueError('Invalid customer.')
 rows=_items(db,business,data.items);subtotal=sum((r[4] for r in rows),Decimal('0'));total=subtotal-data.discount+data.tax
 if total<0:raise ValueError('Discount cannot exceed subtotal plus tax.')
 paid=sum((x.amount for x in data.payments),Decimal('0'))
 if any(x.payment_method not in METHODS for x in data.payments):raise ValueError('Invalid payment method.')
 if paid>total:raise ValueError('Payments exceed sale total.')
 if paid<total and not data.customer_id:raise ValueError('Customer is required for unpaid or partial sales.')
 # lock stock rows in stable item-key order and fail before persisting sale
 stock=[]
 for x,p,v,base,line in sorted(rows,key=lambda r:str(r[2].id if r[2] else r[1].id)):
  if p.track_stock:
   row=inventory_row(db,business,data.branch_id,p.id,v.id if v else None,True)
   if row.quantity<base:raise ValueError('Insufficient stock.')
   stock.append((row,p,v,base))
 status='PAID' if paid==total else ('PARTIALLY_PAID' if paid else 'UNPAID')
 sale=Sale(business_id=business,branch_id=data.branch_id,customer_id=data.customer_id,receipt_number=data.receipt_number.strip(),payment_status=status,subtotal=subtotal,discount=data.discount,tax=data.tax,total=total,amount_paid=paid,balance=total-paid,notes=data.notes,created_by=user);db.add(sale);db.flush()
 for x,p,v,base,line in rows:db.add(SaleItem(sale_id=sale.id,product_id=p.id,variant_id=v.id if v else None,unit_id=x.unit_id,quantity=x.quantity,quantity_in_base=base,unit_price=x.unit_price,discount=x.discount,line_total=line))
 for x in data.payments:db.add(SalePayment(business_id=business,sale_id=sale.id,amount=x.amount,payment_method=x.payment_method,reference=x.reference,recorded_by=user))
 # A receivable represents only the debt principal created at checkout. Initial
 # payments are already represented by the SalePayment rows above.
 if sale.balance > 0:
  db.add(Receivable(business_id=business,customer_id=sale.customer_id,sale_id=sale.id,original_amount=sale.balance,amount_paid=Decimal('0'),balance=sale.balance,status='OPEN'))
 for row,p,v,base in stock:
  previous=row.quantity;row.quantity=previous-base;db.add(StockAdjustment(business_id=business,branch_id=data.branch_id,product_id=p.id,variant_id=v.id if v else None,quantity_changed=-base,previous_quantity=previous,resulting_quantity=row.quantity,reason='SALE',comment=f'Sale {sale.receipt_number} ({sale.id})',performed_by_user_id=user))
 db.add(SaleIdempotency(business_id=business,user_id=user,idempotency_key=key,sale_id=sale.id));record_audit(db,business,'SALE_CREATED','sale',sale.id,user,data.branch_id,{'receipt_number':sale.receipt_number,'total':str(sale.total)});db.commit();db.refresh(sale);return sale,False
def get_sale(db,business,sale_id):
 sale=db.scalar(select(Sale).where(Sale.id==sale_id,Sale.business_id==business))
 if not sale:raise LookupError('Sale not found.')
 return sale
def cancel_sale(db,business,sale_id):
 sale=get_sale(db,business,sale_id)
 if sale.lifecycle_status=='CANCELLED':return sale
 # Sales with stock items need a dedicated returns/reversal flow.
 if db.scalar(select(SaleItem).join(Product,SaleItem.product_id==Product.id).where(SaleItem.sale_id==sale.id,Product.track_stock.is_(True))):raise ValueError('Stock-affecting sale cannot be cancelled without a reversal.')
 sale.lifecycle_status='CANCELLED';record_audit(db,business,'SALE_CANCELLED','sale',sale.id,sale.created_by,sale.branch_id,{});db.commit();db.refresh(sale);return sale
