"""Core ERP routes: companies, parties, items, invoices, expenses, dashboard, reports, users, activity."""
from datetime import datetime, timezone, timedelta
from typing import Optional, List, Literal
import re
from bson import ObjectId
from fastapi import APIRouter, HTTPException, Depends, Request, Query
from pydantic import BaseModel

from auth import get_current_user, require_admin, hash_password
from permissions import require_permission


# ============ Helpers ============
def _oid(value: str) -> ObjectId:
    try:
        return ObjectId(value)
    except Exception:
        raise HTTPException(status_code=400, detail=f"Invalid id: {value}")


def _ser(doc: dict) -> dict:
    if not doc:
        return doc
    doc = dict(doc)
    doc["id"] = str(doc.pop("_id"))
    return doc


def _normalize_item_images(doc: dict) -> dict:
    """Keep legacy photo_url, new cover_image and new images[] in sync.

    Rules:
      - Cap images at 10.
      - If images list is provided, cover_image = images[0], photo_url = images[0].
      - If only cover_image is provided (no list), images = [cover_image].
      - If only legacy photo_url is provided, mirror it into cover_image and images=[photo_url].
      - Filters empty / falsy entries and de-dupes while preserving order.
    """
    if not isinstance(doc, dict):
        return doc
    images = doc.get("images")
    cover = (doc.get("cover_image") or "").strip()
    legacy = (doc.get("photo_url") or "").strip()
    # De-dupe + filter
    if isinstance(images, list):
        seen = set()
        clean = []
        for x in images:
            if not x:
                continue
            s = str(x)
            if s in seen:
                continue
            seen.add(s)
            clean.append(s)
        images = clean[:10]
    else:
        images = []
    if images:
        cover = images[0]
    elif cover:
        images = [cover]
    elif legacy:
        cover = legacy
        images = [legacy]
    # Mirror back
    doc["images"] = images
    doc["cover_image"] = cover
    doc["photo_url"] = cover or legacy
    return doc


async def _log(db, user, action: str, entity: str, entity_id: Optional[str] = None, meta: Optional[dict] = None):
    await db.activity_logs.insert_one({
        "user_id": user["id"],
        "user_email": user["email"],
        "action": action,
        "entity": entity,
        "entity_id": entity_id,
        "meta": meta or {},
        "timestamp": datetime.now(timezone.utc).isoformat(),
    })


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


# ============ Models ============
class CompanyIn(BaseModel):
    name: str
    legal_name: Optional[str] = ""
    gstin: Optional[str] = ""
    pan: Optional[str] = ""
    state: Optional[str] = ""
    address: Optional[str] = ""
    phone: Optional[str] = ""
    email: Optional[str] = ""
    industry: Optional[str] = "Retail"
    branches: List[str] = []


class PartyIn(BaseModel):
    name: str
    type: Literal["customer", "vendor"] = "customer"
    gstin: Optional[str] = ""
    phone: Optional[str] = ""
    email: Optional[str] = ""
    address: Optional[str] = ""
    shipping_address: Optional[str] = ""    # Vyapar parity — separate shipping address
    state: Optional[str] = ""
    opening_balance: float = 0.0
    opening_balance_type: Literal["debit", "credit"] = "debit"   # debit = to receive, credit = to pay
    credit_limit: float = 0.0
    photo_url: Optional[str] = ""           # Circular profile / shop-front image
    documents: Optional[list] = None        # list of {kind, url, label} for GST/PAN/Aadhaar
    # --- Map integration (optional; null when location not set) ---
    latitude: Optional[float] = None
    longitude: Optional[float] = None
    geofence_radius_m: Optional[float] = None    # alert when salesman enters this radius (metres)

class ItemIn(BaseModel):
    name: str
    code: Optional[str] = ""
    barcode: Optional[str] = ""
    hsn: Optional[str] = ""
    unit: str = "PCS"  # legacy / display
    base_unit: Optional[str] = "PCS"
    secondary_unit: Optional[str] = ""
    conversion_ratio: float = 1.0  # 1 secondary = N base
    category: Optional[str] = "General"
    gst_rate: float = 18.0
    sale_price: float = 0.0
    wholesale_price: float = 0.0
    purchase_price: float = 0.0
    mrp: float = 0.0
    opening_stock: float = 0.0
    current_stock: float = 0.0
    low_stock_threshold: float = 5.0
    allow_decimal: bool = True
    batch_tracking: bool = False
    serial_tracking: bool = False
    batch_no: Optional[str] = ""
    serial_no: Optional[str] = ""
    # v12 — Batch & Expiry tracking (UI fields, FIFO consumption added later)
    expiry_date: Optional[str] = ""        # ISO date (YYYY-MM-DD) — when oldest stock expires
    mfg_date: Optional[str] = ""           # ISO date — manufacturing date
    mfg_lot: Optional[str] = ""            # manufacturer lot number
    photo_url: Optional[str] = ""        # Legacy: kept in sync with cover_image for backward compat
    cover_image: Optional[str] = ""      # v12.10: primary image displayed in lists / billing
    images: Optional[List[str]] = None   # v12.10: full gallery (up to 10), index 0 = cover
    name_translations: Optional[dict[str, str]] = None   # { lang_code: translated_name }


class InvoiceLine(BaseModel):
    item_id: Optional[str] = None
    name: str
    hsn: Optional[str] = ""
    qty: float = 1.0
    unit: str = "PCS"
    unit_used: Optional[str] = "base"  # "base" | "secondary"
    conversion_ratio: float = 1.0
    rate: float = 0.0
    discount: float = 0.0
    gst_rate: float = 18.0


class InvoiceIn(BaseModel):
    type: Literal["sale", "purchase", "quotation", "challan", "credit_note", "debit_note", "return", "sale_order", "proforma"] = "sale"
    tax_mode: Literal["GST", "NON_GST"] = "GST"     # GST = compute CGST/SGST/IGST · NON_GST = retail-style, no tax
    prefix_id: Optional[str] = None                  # v12: user-selected series; null = use default
    invoice_no_override: Optional[str] = None        # v12: manual invoice number (Custom mode); when set, overrides auto-generation
    party_id: Optional[str] = None
    party_name: Optional[str] = ""
    party_gstin: Optional[str] = ""
    party_state: Optional[str] = ""
    invoice_date: Optional[str] = None
    due_date: Optional[str] = None
    notes: Optional[str] = ""
    lines: List[InvoiceLine]
    extra_discount: float = 0.0
    round_off: float = 0.0
    tax_inclusive: bool = False
    payment_received: float = 0.0
    payment_mode: Optional[str] = "Cash"
    status: Optional[str] = "unpaid"  # unpaid, partial, paid
    # Logistics & copy-type controls
    transport_name: Optional[str] = ""
    vehicle_no: Optional[str] = ""
    delivery_location: Optional[str] = ""
    delivery_charge: float = 0.0
    copy_type: Optional[str] = "ORIGINAL"   # ORIGINAL / DUPLICATE / TRIPLICATE
    terms_text: Optional[str] = ""
    # v10.2: Vyapar-parity advanced fields
    description: Optional[str] = ""                                  # dedicated descriptive notes
    adjustment: float = 0.0                                          # +/- adjustment (post-discount, pre-tax-round)
    round_off_mode: Optional[str] = "nearest_rupee"                  # nearest_rupee | nearest_50p | manual
    auto_round_off: bool = True
    charges: Optional[dict] = None                                   # {loading, unloading, freight, insurance, labour, other}
    packaging_charge: float = 0.0
    attachments: Optional[List[dict]] = None                         # [{name, data_url, size, mime}]
    terms_template_id: Optional[str] = None                          # id of terms_templates entry used
    mixed_payments: Optional[List[dict]] = None                      # [{mode, amount, ref}]
    billing_name: Optional[str] = ""
    billing_phone: Optional[str] = ""
    billing_address: Optional[str] = ""
    shipping_address: Optional[str] = ""
    payment_terms: Optional[str] = "credit"                          # credit | cash


class ExpenseLineItem(BaseModel):
    """v12.34 — Optional line-item rows for Vyapar-style full-page entry.
    Old expenses (without line items) keep working — `amount` remains source-of-truth."""
    hsn_code: Optional[str] = ""
    description: str = ""
    qty: float = 1
    price: float = 0
    discount_pct: float = 0          # percent (0-100)
    tax_pct: float = 0               # GST %
    amount: float = 0                # computed on client; we don't trust + recompute server-side


class ExpenseIn(BaseModel):
    # --- Existing fields (unchanged) ---
    date: Optional[str] = None
    category: str
    amount: float
    payment_mode: Optional[str] = "Cash"
    notes: Optional[str] = ""
    vendor: Optional[str] = ""

    # --- v12.34 Vyapar-style optional fields (all nullable for backward compat) ---
    expense_no: Optional[str] = None
    bill_date: Optional[str] = None
    payment_terms: Optional[str] = None       # e.g. "Net 30", "Cash on delivery"
    due_date: Optional[str] = None
    state_of_supply: Optional[str] = None
    gst_enabled: Optional[bool] = False

    party_id: Optional[str] = None            # links to existing parties collection
    line_items: Optional[List[ExpenseLineItem]] = None

    # transport / delivery
    transport_name: Optional[str] = None
    extra_days: Optional[int] = None
    vehicle_number: Optional[str] = None
    delivery_location: Optional[str] = None

    # payment extras
    payment_reference: Optional[str] = None

    # extra charges (right-summary)
    loading_charge: Optional[float] = 0
    unloading_charge: Optional[float] = 0
    delivery_charge: Optional[float] = 0
    packaging_charge: Optional[float] = 0
    adjustment: Optional[float] = 0           # +/- adjustment

    # attachments (Cloud-storage URLs OR data URIs; admin-managed)
    attachments: Optional[List[str]] = None


class UserIn(BaseModel):
    email: str
    password: str
    name: str
    role: str = "cashier"  # validated against preset + custom roles in handler
    phone: Optional[str] = ""
    is_active: bool = True


class UserUpdate(BaseModel):
    name: Optional[str] = None
    role: Optional[str] = None  # any preset or custom role name
    password: Optional[str] = None
    phone: Optional[str] = None
    is_active: Optional[bool] = None


# ============ Router ============
router = APIRouter(prefix="/api")


# ----- Companies -----
@router.get("/companies")
async def list_companies(request: Request, user=Depends(get_current_user)):
    db = request.app.state.db
    docs = await db.companies.find().to_list(500)
    return [_ser(d) for d in docs]


@router.post("/companies")
async def create_company(payload: CompanyIn, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    doc = payload.model_dump()
    doc["created_at"] = _now_iso()
    res = await db.companies.insert_one(doc)
    await _log(db, user, "create", "company", str(res.inserted_id), {"name": payload.name})
    out = await db.companies.find_one({"_id": res.inserted_id})
    return _ser(out)


@router.put("/companies/{cid}")
async def update_company(cid: str, payload: CompanyIn, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    await db.companies.update_one({"_id": _oid(cid)}, {"$set": payload.model_dump()})
    await _log(db, user, "update", "company", cid)
    out = await db.companies.find_one({"_id": _oid(cid)})
    return _ser(out)


@router.delete("/companies/{cid}")
async def delete_company(cid: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    await db.companies.delete_one({"_id": _oid(cid)})
    await _log(db, user, "delete", "company", cid)
    return {"ok": True}


# ----- Parties -----
@router.get("/parties")
async def list_parties(request: Request, company_id: Optional[str] = None, type: Optional[str] = None, user=Depends(get_current_user)):
    db = request.app.state.db
    q = {}
    if company_id:
        q["company_id"] = company_id
    if type:
        q["type"] = type
    docs = await db.parties.find(q).sort("name", 1).to_list(2000)
    # Compute outstanding in a single batch query (avoid N+1)
    party_ids = [str(p["_id"]) for p in docs]
    outstanding_by_party = {}
    if party_ids:
        invs = await db.invoices.find(
            {"party_id": {"$in": party_ids}, "type": {"$in": ["sale", "return"]}},
            {"party_id": 1, "total": 1, "payment_received": 1},
        ).to_list(20000)
        for inv in invs:
            pid = inv.get("party_id")
            if pid:
                outstanding_by_party[pid] = outstanding_by_party.get(pid, 0.0) + (
                    float(inv.get("total", 0)) - float(inv.get("payment_received", 0))
                )
    for party in docs:
        party["outstanding"] = round(outstanding_by_party.get(str(party["_id"]), 0.0), 2)
    return [_ser(party) for party in docs]


@router.post("/parties")
async def create_party(payload: PartyIn, request: Request, company_id: str = Query(...), user=Depends(require_admin)):
    db = request.app.state.db
    # Guard: name must be present after stripping whitespace
    name = (payload.name or "").strip()
    if not name:
        raise HTTPException(status_code=422, detail="Party name is required and cannot be empty.")
    # Guard: company_id must be a real ObjectId, not the literal "undefined"/"null" from frontend
    if not company_id or company_id in ("undefined", "null"):
        raise HTTPException(status_code=400, detail="Active company is required. Please select a company first.")
    try:
        ObjectId(company_id)
    except Exception:
        raise HTTPException(status_code=400, detail=f"Invalid company_id: {company_id}")
    doc = payload.model_dump()
    doc["name"] = name                                  # store trimmed
    doc["gstin"] = (doc.get("gstin") or "").strip().upper()
    doc["phone"] = (doc.get("phone") or "").strip()
    doc["email"] = (doc.get("email") or "").strip()
    doc["company_id"] = company_id
    doc["created_at"] = _now_iso()
    # Duplicate-GSTIN guard within the same company (skip empty GSTINs)
    if doc["gstin"]:
        existing = await db.parties.find_one({"company_id": company_id, "gstin": doc["gstin"]})
        if existing:
            raise HTTPException(
                status_code=409,
                detail=f"This GSTIN is already used by '{existing.get('name', '?')}' in this company.",
            )
    try:
        res = await db.parties.insert_one(doc)
    except Exception as ex:
        raise HTTPException(status_code=500, detail=f"Database error while saving party: {ex}")
    await _log(db, user, "create", "party", str(res.inserted_id), {"name": name})
    out = await db.parties.find_one({"_id": res.inserted_id})
    return _ser(out)


@router.get("/parties/{pid}")
async def get_party(pid: str, request: Request, user=Depends(get_current_user)):
    db = request.app.state.db
    doc = await db.parties.find_one({"_id": _oid(pid)})
    if not doc:
        raise HTTPException(404, "Party not found")
    return _ser(doc)


@router.put("/parties/{pid}")
async def update_party(pid: str, payload: PartyIn, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    name = (payload.name or "").strip()
    if not name:
        raise HTTPException(status_code=422, detail="Party name is required and cannot be empty.")
    doc = payload.model_dump()
    doc["name"] = name
    doc["gstin"] = (doc.get("gstin") or "").strip().upper()
    doc["phone"] = (doc.get("phone") or "").strip()
    doc["email"] = (doc.get("email") or "").strip()
    # Duplicate-GSTIN guard on edit — scope to same company, exclude self
    if doc["gstin"]:
        existing_party = await db.parties.find_one({"_id": _oid(pid)}, {"company_id": 1})
        cid = (existing_party or {}).get("company_id")
        if cid:
            other = await db.parties.find_one({
                "_id": {"$ne": _oid(pid)},
                "company_id": cid,
                "gstin": doc["gstin"],
            })
            if other:
                raise HTTPException(
                    status_code=409,
                    detail=f"This GSTIN is already used by '{other.get('name', '?')}' in this company.",
                )
    try:
        result = await db.parties.update_one({"_id": _oid(pid)}, {"$set": doc})
    except Exception as ex:
        raise HTTPException(status_code=500, detail=f"Database error while updating party: {ex}")
    if result.matched_count == 0:
        raise HTTPException(status_code=404, detail="Party not found")
    await _log(db, user, "update", "party", pid)
    out = await db.parties.find_one({"_id": _oid(pid)})
    return _ser(out)


@router.delete("/parties/{pid}")
async def delete_party(pid: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    doc = await db.parties.find_one({"_id": _oid(pid)})
    if not doc:
        raise HTTPException(404, "Party not found")
    doc["deleted_at"] = _now_iso()
    doc["deleted_by"] = user["email"]
    await db.parties_trash.insert_one(doc)
    await db.parties.delete_one({"_id": _oid(pid)})
    await _log(db, user, "delete", "party", pid, {"name": doc.get("name")})
    return {"ok": True}


@router.post("/parties/{pid}/restore")
async def restore_party(pid: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    doc = await db.parties_trash.find_one({"_id": _oid(pid)})
    if not doc:
        raise HTTPException(404, "Not in trash")
    doc.pop("deleted_at", None)
    doc.pop("deleted_by", None)
    await db.parties.insert_one(doc)
    await db.parties_trash.delete_one({"_id": _oid(pid)})
    await _log(db, user, "restore", "party", pid, {"name": doc.get("name")})
    return _ser(doc)


@router.delete("/parties-trash/{pid}")
async def purge_party(pid: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    res = await db.parties_trash.delete_one({"_id": _oid(pid)})
    if res.deleted_count == 0:
        raise HTTPException(404, "Not in trash")
    await _log(db, user, "purge", "party", pid)
    return {"ok": True}


@router.post("/parties/bulk-delete")
async def bulk_delete_parties(payload: "_BulkIds", request: Request, user=Depends(require_admin)):
    """Move many parties to the trash in one shot. Returns count moved + skipped.
    Mirrors `/items/bulk-delete` so frontend can call both with the same shape."""
    db = request.app.state.db
    if not payload.ids:
        raise HTTPException(422, "ids required")
    oids = [_oid(i) for i in payload.ids]
    docs = await db.parties.find({"_id": {"$in": oids}}).to_list(50000)
    if not docs:
        return {"moved": 0, "skipped": len(payload.ids)}
    now = _now_iso()
    for d in docs:
        d["deleted_at"] = now
        d["deleted_by"] = user["email"]
    await db.parties_trash.insert_many(docs)
    res = await db.parties.delete_many({"_id": {"$in": [d["_id"] for d in docs]}})
    await _log(db, user, "bulk_delete", "parties", None, {"count": res.deleted_count})
    return {"moved": res.deleted_count, "skipped": len(payload.ids) - res.deleted_count}


@router.post("/parties/bulk-restore")
async def bulk_restore_parties(payload: "_BulkIds", request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    if not payload.ids:
        raise HTTPException(422, "ids required")
    oids = [_oid(i) for i in payload.ids]
    docs = await db.parties_trash.find({"_id": {"$in": oids}}).to_list(50000)
    if not docs:
        return {"restored": 0}
    for d in docs:
        d.pop("deleted_at", None)
        d.pop("deleted_by", None)
    await db.parties.insert_many(docs)
    res = await db.parties_trash.delete_many({"_id": {"$in": [d["_id"] for d in docs]}})
    await _log(db, user, "bulk_restore", "parties", None, {"count": res.deleted_count})
    return {"restored": res.deleted_count}


@router.get("/parties-trash")
async def list_parties_trash(request: Request, company_id: Optional[str] = None, user=Depends(require_admin)):
    db = request.app.state.db
    q = {}
    if company_id:
        q["company_id"] = company_id
    docs = await db.parties_trash.find(q).sort("deleted_at", -1).to_list(2000)
    return [_ser(d) for d in docs]


# ----- Items -----
@router.get("/items")
async def list_items(request: Request, company_id: Optional[str] = None, user=Depends(get_current_user)):
    db = request.app.state.db
    q = {}
    if company_id:
        q["company_id"] = company_id
    docs = await db.items.find(q).sort("name", 1).to_list(50000)
    return [_ser(_normalize_item_images(d)) for d in docs]


# ----- v12: Inventory Alerts — Low Stock + Expiry warnings -----
@router.get("/items/alerts")
async def items_alerts(
    request: Request,
    company_id: str = Query(...),
    expiry_within_days: int = 30,
    user=Depends(get_current_user),
):
    """Returns items that need owner attention.

    Three buckets:
      - low_stock     → current_stock <= low_stock_threshold (and > 0)
      - out_of_stock  → current_stock <= 0
      - expiring      → expiry_date is set AND expiry_date <= today + N days
      - expired       → expiry_date is set AND expiry_date < today
    """
    db = request.app.state.db
    today = datetime.now(timezone.utc).date()
    cutoff = (today + timedelta(days=expiry_within_days)).isoformat()
    today_iso = today.isoformat()
    items = await db.items.find({"company_id": company_id}).to_list(50000)
    low, out, expiring, expired = [], [], [], []
    for d in items:
        cs = float(d.get("current_stock", 0) or 0)
        threshold = float(d.get("low_stock_threshold", 5) or 5)
        if cs <= 0:
            out.append(_ser(d))
        elif cs <= threshold:
            low.append(_ser(d))
        ed = (d.get("expiry_date") or "").strip()
        if ed:
            if ed < today_iso:
                expired.append(_ser(d))
            elif ed <= cutoff:
                expiring.append(_ser(d))
    return {
        "low_stock": low,
        "out_of_stock": out,
        "expiring_soon": expiring,
        "expired": expired,
        "as_of": today_iso,
        "expiry_within_days": expiry_within_days,
    }



@router.get("/items/search")
async def search_items(
    request: Request,
    company_id: str = Query(...),
    q: str = Query("", description="Search term — matches name, code, barcode, hsn"),
    limit: int = Query(20, ge=1, le=100),
    user=Depends(get_current_user),
):
    """Fast server-side item search for the billing screen item-picker.

    Returns a slim subset of fields so we don't ship 4 MB of inventory on every
    keystroke. Uses Mongo indexed regex on (company_id + name|code|barcode|hsn).
    """
    db = request.app.state.db
    needle = (q or "").strip()
    base_filter = {"company_id": company_id}
    if needle:
        # `re.escape`-equivalent: $regex with $options="i" needs us to escape user input.
        import re as _re
        pat = _re.escape(needle)
        base_filter["$or"] = [
            {"name":    {"$regex": pat, "$options": "i"}},
            {"code":    {"$regex": f"^{pat}", "$options": "i"}},   # SKU prefix is fastest
            {"barcode": {"$regex": f"^{pat}", "$options": "i"}},   # barcode prefix for scanners
            {"hsn":     {"$regex": f"^{pat}", "$options": "i"}},
        ]
    # Project ONLY the fields the picker needs — slim payload for typing speed
    projection = {
        "name": 1, "code": 1, "barcode": 1, "hsn": 1, "category": 1,
        "sale_price": 1, "wholesale_price": 1, "purchase_price": 1, "mrp": 1,
        "gst_rate": 1, "unit": 1, "base_unit": 1, "secondary_unit": 1,
        "conversion_ratio": 1, "current_stock": 1, "low_stock_threshold": 1,
        "photo_url": 1, "cover_image": 1, "allow_decimal": 1,
    }
    docs = await db.items.find(base_filter, projection).sort("name", 1).to_list(limit)
    return [_ser(_normalize_item_images(d)) for d in docs]


@router.post("/items")
async def create_item(payload: ItemIn, request: Request, company_id: str = Query(...), user=Depends(require_admin)):
    db = request.app.state.db
    # Duplicate guard — same barcode in the same company is a hard block
    if payload.barcode:
        existing_bc = await db.items.find_one(
            {"company_id": company_id, "barcode": payload.barcode},
            {"_id": 1, "name": 1, "barcode": 1, "code": 1, "current_stock": 1, "base_unit": 1},
        )
        if existing_bc:
            raise HTTPException(
                409,
                {
                    "duplicate_kind": "barcode",
                    "existing_id": str(existing_bc["_id"]),
                    "existing_name": existing_bc.get("name"),
                    "current_stock": existing_bc.get("current_stock", 0),
                    "base_unit": existing_bc.get("base_unit", "PCS"),
                    "message": f"Item with barcode {payload.barcode} already exists: {existing_bc.get('name')}. Update stock instead?",
                },
            )
    doc = payload.model_dump()
    doc["company_id"] = company_id
    if not doc.get("current_stock"):
        doc["current_stock"] = doc.get("opening_stock", 0)
    doc = _normalize_item_images(doc)
    doc["created_at"] = _now_iso()
    res = await db.items.insert_one(doc)
    await _log(db, user, "create", "item", str(res.inserted_id), {"name": payload.name})
    out = await db.items.find_one({"_id": res.inserted_id})
    return _ser(_normalize_item_images(out))


@router.get("/items/check-duplicate")
async def check_item_duplicate(
    request: Request,
    company_id: str = Query(...),
    barcode: str = Query(""),
    name: str = Query(""),
    user=Depends(require_admin),
):
    """Lightweight duplicate probe for the Floating-AI Auto Product Create dialog.
    Returns the matching item (if any) for barcode or fuzzy-name match within the company."""
    db = request.app.state.db
    out = {"barcode_match": None, "name_match": None}
    if barcode.strip():
        bc = await db.items.find_one(
            {"company_id": company_id, "barcode": barcode.strip()},
            {"_id": 1, "name": 1, "barcode": 1, "current_stock": 1, "base_unit": 1, "sale_price": 1},
        )
        if bc:
            out["barcode_match"] = _ser(bc)
    if name.strip():
        # Case-insensitive contains match
        import re as _re
        nm = await db.items.find_one(
            {"company_id": company_id, "name": {"$regex": _re.escape(name.strip()), "$options": "i"}},
            {"_id": 1, "name": 1, "barcode": 1, "current_stock": 1, "base_unit": 1, "sale_price": 1},
        )
        if nm:
            out["name_match"] = _ser(nm)
    return out


@router.put("/items/{iid}")
async def update_item(iid: str, payload: ItemIn, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    update_doc = _normalize_item_images(payload.model_dump())
    await db.items.update_one({"_id": _oid(iid)}, {"$set": update_doc})
    await _log(db, user, "update", "item", iid)
    out = await db.items.find_one({"_id": _oid(iid)})
    return _ser(_normalize_item_images(out))


@router.delete("/items/{iid}")
async def delete_item(iid: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    doc = await db.items.find_one({"_id": _oid(iid)})
    if not doc:
        raise HTTPException(404, "Item not found")
    doc["deleted_at"] = _now_iso()
    doc["deleted_by"] = user["email"]
    await db.items_trash.insert_one(doc)
    await db.items.delete_one({"_id": _oid(iid)})
    await _log(db, user, "delete", "item", iid, {"name": doc.get("name")})
    return {"ok": True}


class _BulkIds(BaseModel):
    ids: list[str] = []


@router.post("/items/bulk-delete")
async def bulk_delete_items(payload: _BulkIds, request: Request, user=Depends(require_admin)):
    """Move many items to the trash in one shot. Returns count moved + skipped."""
    db = request.app.state.db
    if not payload.ids:
        raise HTTPException(422, "ids required")
    oids = [_oid(i) for i in payload.ids]
    docs = await db.items.find({"_id": {"$in": oids}}).to_list(50000)
    if not docs:
        return {"moved": 0, "skipped": len(payload.ids)}
    now = _now_iso()
    for d in docs:
        d["deleted_at"] = now
        d["deleted_by"] = user["email"]
    await db.items_trash.insert_many(docs)
    res = await db.items.delete_many({"_id": {"$in": [d["_id"] for d in docs]}})
    await _log(db, user, "bulk_delete", "items", None, {"count": res.deleted_count})
    return {"moved": res.deleted_count, "skipped": len(payload.ids) - res.deleted_count}


@router.post("/items/delete-all")
async def delete_all_items(request: Request, company_id: str = Query(...), user=Depends(require_admin)):
    """Move every item of a company to trash. Confirmation must happen client-side."""
    db = request.app.state.db
    docs = await db.items.find({"company_id": company_id}).to_list(100000)
    if not docs:
        return {"moved": 0}
    now = _now_iso()
    for d in docs:
        d["deleted_at"] = now
        d["deleted_by"] = user["email"]
    await db.items_trash.insert_many(docs)
    res = await db.items.delete_many({"company_id": company_id})
    await _log(db, user, "delete_all", "items", None, {"count": res.deleted_count, "company_id": company_id})
    return {"moved": res.deleted_count}


@router.post("/items/{iid}/restore")
async def restore_item(iid: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    doc = await db.items_trash.find_one({"_id": _oid(iid)})
    if not doc:
        raise HTTPException(404, "Not in trash")
    doc.pop("deleted_at", None)
    doc.pop("deleted_by", None)
    doc["restored_at"] = _now_iso()
    doc["restored_by"] = user["email"]
    await db.items.insert_one(doc)
    await db.items_trash.delete_one({"_id": _oid(iid)})
    await _log(db, user, "restore", "item", iid, {"name": doc.get("name")})
    return _ser(doc)


@router.post("/items/bulk-restore")
async def bulk_restore_items(payload: _BulkIds, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    if not payload.ids:
        raise HTTPException(422, "ids required")
    oids = [_oid(i) for i in payload.ids]
    docs = await db.items_trash.find({"_id": {"$in": oids}}).to_list(50000)
    if not docs:
        return {"restored": 0, "skipped": len(payload.ids)}
    now = _now_iso()
    for d in docs:
        d.pop("deleted_at", None)
        d.pop("deleted_by", None)
        d["restored_at"] = now
        d["restored_by"] = user["email"]
    await db.items.insert_many(docs)
    res = await db.items_trash.delete_many({"_id": {"$in": [d["_id"] for d in docs]}})
    await _log(db, user, "bulk_restore", "items", None, {"count": res.deleted_count})
    return {"restored": res.deleted_count, "skipped": len(payload.ids) - res.deleted_count}


@router.delete("/items-trash/{iid}")
async def purge_item(iid: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    res = await db.items_trash.delete_one({"_id": _oid(iid)})
    if res.deleted_count == 0:
        raise HTTPException(404, "Not in trash")
    await _log(db, user, "purge", "item", iid)
    return {"ok": True}


@router.post("/items-trash/bulk-purge")
async def bulk_purge_items(payload: _BulkIds, request: Request, user=Depends(require_admin)):
    """Permanently delete trashed items. Frontend must double-confirm."""
    db = request.app.state.db
    if not payload.ids:
        raise HTTPException(422, "ids required")
    oids = [_oid(i) for i in payload.ids]
    res = await db.items_trash.delete_many({"_id": {"$in": oids}})
    await _log(db, user, "bulk_purge", "items", None, {"count": res.deleted_count})
    return {"purged": res.deleted_count}


@router.get("/items-trash")
async def list_items_trash(request: Request, company_id: Optional[str] = None, user=Depends(require_admin)):
    db = request.app.state.db
    q = {}
    if company_id:
        q["company_id"] = company_id
    docs = await db.items_trash.find(q).sort("deleted_at", -1).to_list(2000)
    return [_ser(d) for d in docs]


# ----- Invoices -----
async def _next_invoice_no(db, company_id: str, inv_type: str) -> str:
    """Atomically reserve the next invoice number.

    Routes through the new `txn_prefixes` module (auto-bootstraps a default
    prefix on first use). Falls back to the legacy counter ONLY if the prefix
    module itself errors (defensive — should never happen in normal flow).
    """
    try:
        from txn_prefixes import resolve_next_invoice_no
        inv_no, _pid = await resolve_next_invoice_no(db, company_id, inv_type)
        return inv_no
    except Exception:
        pass

    # Legacy fallback (rare — only if prefix module fails)
    key = f"{company_id}:{inv_type}"
    res = await db.counters.find_one_and_update(
        {"_id": key},
        {"$inc": {"seq": 1}},
        upsert=True,
        return_document=True,
    )
    seq = (res or {}).get("seq", 1)
    prefix = {
        "sale": "INV",
        "purchase": "PUR",
        "quotation": "QUO",
        "challan": "CHL",
        "credit_note": "CN",
        "debit_note": "DN",
        "return": "RET",
        "sale_order": "SO",
        "proforma": "PI",
    }.get(inv_type, "DOC")
    year = datetime.now(timezone.utc).strftime("%y")
    return f"{prefix}/{year}/{seq:05d}"


def _calc_invoice(payload: InvoiceIn, company_state: str = "") -> dict:
    subtotal = 0.0
    total_gst = 0.0
    lines_out = []
    # NON_GST mode: zero out all tax computations regardless of line-level gst_rate.
    # This is the permanent fix per requirement — Vyapar-style hard switch.
    is_gst = payload.tax_mode == "GST"
    interstate = bool(
        is_gst and payload.party_state and company_state
        and payload.party_state.strip().lower() != company_state.strip().lower()
    )
    for ln in payload.lines:
        # Effective GST rate respects tax_mode toggle
        effective_gst_rate = ln.gst_rate if is_gst else 0.0
        if payload.tax_inclusive and effective_gst_rate > 0:
            # Rate already includes GST — back-calc taxable
            gross = ln.qty * ln.rate
            disc = gross * (ln.discount / 100.0)
            gross_after_disc = gross - disc
            line_taxable = gross_after_disc / (1 + effective_gst_rate / 100.0)
            line_gst = gross_after_disc - line_taxable
        else:
            line_total = ln.qty * ln.rate
            line_disc = line_total * (ln.discount / 100.0)
            line_taxable = line_total - line_disc
            line_gst = line_taxable * (effective_gst_rate / 100.0)
        subtotal += line_taxable
        total_gst += line_gst
        lines_out.append({
            **ln.model_dump(),
            # In NON_GST mode, overwrite gst_rate to 0 in the stored line too
            "gst_rate": effective_gst_rate,
            "taxable": round(line_taxable, 2),
            "gst_amount": round(line_gst, 2),
            "total": round(line_taxable + line_gst, 2),
        })
    if interstate:
        cgst = 0.0
        sgst = 0.0
        igst = round(total_gst, 2)
    else:
        cgst = round(total_gst / 2, 2)
        sgst = round(total_gst / 2, 2)
        igst = 0.0
    # Compose additional charges (v10.2)
    charges_dict = dict(payload.charges or {})
    charges_total = sum(float(charges_dict.get(k, 0) or 0) for k in ("loading", "unloading", "freight", "insurance", "labour", "other"))
    packaging_charge = float(payload.packaging_charge or 0)
    adjustment = float(payload.adjustment or 0)

    pre_round_total = (
        subtotal + total_gst
        - payload.extra_discount
        + (payload.delivery_charge or 0)
        + packaging_charge
        + charges_total
        + adjustment
    )
    # Apply auto round-off if requested
    auto_ro = float(payload.round_off or 0)
    if bool(payload.auto_round_off):
        mode = (payload.round_off_mode or "nearest_rupee").lower()
        if mode == "nearest_rupee":
            target = round(pre_round_total)
            auto_ro = round(target - pre_round_total, 2)
        elif mode in ("nearest_50p", "nearest_50_paisa"):
            target = round(pre_round_total * 2) / 2
            auto_ro = round(target - pre_round_total, 2)
        # else manual: keep payload.round_off as-is
    grand_total = pre_round_total + auto_ro
    return {
        "tax_mode": payload.tax_mode,                                # "GST" or "NON_GST"
        "subtotal": round(subtotal, 2),
        "total_gst": round(total_gst, 2),
        "gst_amount": round(total_gst, 2),                            # alias for downstream reports
        "cgst": cgst,
        "sgst": sgst,
        "igst": igst,
        "interstate": interstate,
        "tax_inclusive": payload.tax_inclusive,
        "extra_discount": round(payload.extra_discount, 2),
        "round_off": round(auto_ro, 2),
        "round_off_mode": (payload.round_off_mode or "nearest_rupee"),
        "auto_round_off": bool(payload.auto_round_off),
        "adjustment": round(adjustment, 2),
        "charges": {k: round(float(charges_dict.get(k, 0) or 0), 2) for k in ("loading", "unloading", "freight", "insurance", "labour", "other")},
        "charges_total": round(charges_total, 2),
        "packaging_charge": round(packaging_charge, 2),
        "delivery_charge": round(payload.delivery_charge or 0, 2),
        "transport_name": payload.transport_name or "",
        "vehicle_no": payload.vehicle_no or "",
        "delivery_location": payload.delivery_location or "",
        "copy_type": (payload.copy_type or "ORIGINAL").upper(),
        "terms_text": payload.terms_text or "",
        "terms_template_id": payload.terms_template_id,
        "description": payload.description or "",
        "attachments": payload.attachments or [],
        "mixed_payments": payload.mixed_payments or [],
        "billing_name": payload.billing_name or "",
        "billing_phone": payload.billing_phone or "",
        "billing_address": payload.billing_address or "",
        "shipping_address": payload.shipping_address or "",
        "payment_terms": payload.payment_terms or "credit",
        "total": round(grand_total, 2),
        "lines": lines_out,
    }


def _stock_delta(inv_type: str) -> int:
    """+1 = add to stock, -1 = subtract, 0 = no change."""
    if inv_type in ("sale", "challan"):
        return -1
    if inv_type in ("purchase",):
        return 1
    if inv_type == "return":
        return 1  # sales return adds stock back
    return 0


def _base_qty(line) -> float:
    """Convert any qty + unit_used + conversion_ratio to base-unit qty.
    Accepts either a Pydantic InvoiceLine or a dict from DB."""
    if hasattr(line, "model_dump"):
        d = line.model_dump()
    else:
        d = dict(line or {})
    qty = float(d.get("qty") or 0)
    used = (d.get("unit_used") or "base").lower()
    if used == "secondary":
        ratio = float(d.get("conversion_ratio") or 1) or 1
        return qty * ratio
    return qty


@router.get("/invoices")
async def list_invoices(
    request: Request,
    company_id: Optional[str] = None,
    type: Optional[str] = None,
    q: str = Query("", description="Search term — matches invoice_no or party_name"),
    user=Depends(get_current_user),
):
    db = request.app.state.db
    flt = {}
    if company_id:
        flt["company_id"] = company_id
    if type:
        flt["type"] = type
    if q:
        # Case-insensitive search on invoice_no OR party_name
        needle = re.escape(q.strip())
        flt["$or"] = [
            {"invoice_no": {"$regex": needle, "$options": "i"}},
            {"party_name": {"$regex": needle, "$options": "i"}},
        ]
    docs = await db.invoices.find(flt).sort("created_at", -1).to_list(2000)
    return [_ser(d) for d in docs]


@router.get("/invoices/{inv_id}")
async def get_invoice(inv_id: str, request: Request, user=Depends(get_current_user)):
    db = request.app.state.db
    doc = await db.invoices.find_one({"_id": _oid(inv_id)})
    if not doc:
        raise HTTPException(404, "Invoice not found")
    return _ser(doc)


@router.post("/invoices")
async def create_invoice(payload: InvoiceIn, request: Request, company_id: str = Query(...), user=Depends(get_current_user)):
    db = request.app.state.db
    company = await db.companies.find_one({"_id": _oid(company_id)})
    company_state = (company or {}).get("state", "") if company else ""
    calc = _calc_invoice(payload, company_state=company_state)
    # v12.7 — Multi-user, multi-company unified Auto/Manual sequence.
    # Counter key = (prefix_id, user_email, fy). Manual entries also bump
    # the same counter so the next auto bill continues from the manual.
    user_email = (user.get("email") or "").lower().strip()
    if payload.invoice_no_override and payload.invoice_no_override.strip():
        inv_no = payload.invoice_no_override.strip()
        # Duplicate check is scoped to (company, type, user) so two users on the
        # same browser/company can each maintain their own RM/2026-27/1 sequence
        # per the multi-user spec.
        existing = await db.invoices.find_one(
            {"company_id": company_id, "type": payload.type, "invoice_no": inv_no, "created_by": user_email}
        )
        if existing:
            raise HTTPException(status_code=400, detail=f"Invoice number '{inv_no}' already exists for you. Pick a different number.")
        # Sync the per-user counter forward so the NEXT auto bill continues from this manual one
        try:
            from txn_prefixes import bump_user_counter_for_manual
            prefix_id, financial_year = await bump_user_counter_for_manual(
                db,
                company_id=company_id,
                txn_type=payload.type,
                user_email=user_email,
                manual_invoice_no=inv_no,
                prefix_id=payload.prefix_id,
            )
        except Exception as bump_err:
            # Don't block the bill save if counter bump fails — log and continue
            import logging as _lg
            _lg.getLogger("rm-regal").warning("bump_user_counter_for_manual failed: %s", bump_err)
            prefix_id, financial_year = (payload.prefix_id or ""), ""
    elif payload.prefix_id:
        try:
            from txn_prefixes import resolve_next_invoice_no_per_user
            inv_no, prefix_id, financial_year = await resolve_next_invoice_no_per_user(
                db, company_id=company_id, txn_type=payload.type,
                user_email=user_email, prefix_id=payload.prefix_id,
            )
        except ValueError as ex:
            raise HTTPException(status_code=400, detail=str(ex))
    else:
        # No specific prefix — let the per-user resolver auto-bootstrap the default
        try:
            from txn_prefixes import resolve_next_invoice_no_per_user
            inv_no, prefix_id, financial_year = await resolve_next_invoice_no_per_user(
                db, company_id=company_id, txn_type=payload.type, user_email=user_email,
            )
        except Exception as ex:
            # Fall back to legacy global counter if per-user path fails for any reason
            import logging as _lg
            _lg.getLogger("rm-regal").warning("per-user resolver fell back to legacy: %s", ex)
            inv_no = await _next_invoice_no(db, company_id, payload.type)
            try:
                from txn_prefixes import preview_next_invoice_no
                _peek, prefix_id, financial_year = await preview_next_invoice_no(db, company_id, payload.type)
            except Exception:
                prefix_id, financial_year = None, ""
    status = payload.status or "unpaid"
    if payload.payment_received <= 0:
        status = "unpaid"
    elif payload.payment_received >= calc["total"]:
        status = "paid"
    else:
        status = "partial"
    # Defensive guard — auto-fill party_name/gstin/state from party_id if the
    # client omitted them (keeps q-search and reports accurate for any client).
    party_name = (payload.party_name or "").strip()
    party_gstin = (payload.party_gstin or "").strip()
    party_state = (payload.party_state or "").strip()
    if payload.party_id and not party_name:
        try:
            p = await db.parties.find_one({"_id": _oid(payload.party_id)})
            if p:
                party_name = party_name or p.get("name", "")
                party_gstin = party_gstin or p.get("gstin", "")
                party_state = party_state or p.get("state", "")
        except Exception:
            pass
    doc = {
        "company_id": company_id,
        "invoice_no": inv_no,
        "invoice_prefix_id": prefix_id,
        "financial_year": financial_year,
        "type": payload.type,
        "party_id": payload.party_id,
        "party_name": party_name,
        "party_gstin": party_gstin,
        "party_state": party_state,
        "invoice_date": payload.invoice_date or _now_iso()[:10],
        "due_date": payload.due_date,
        "notes": payload.notes or "",
        "payment_mode": payload.payment_mode,
        "payment_received": payload.payment_received,
        "status": status,
        "created_by": user_email,
        "created_at": _now_iso(),
        **calc,
    }
    res = await db.invoices.insert_one(doc)
    # Update stock
    delta = _stock_delta(payload.type)
    if delta != 0:
        for ln in payload.lines:
            if ln.item_id:
                try:
                    await db.items.update_one(
                        {"_id": _oid(ln.item_id)},
                        {"$inc": {"current_stock": delta * _base_qty(ln)}},
                    )
                except Exception:
                    pass
    await _log(db, user, "create", "invoice", str(res.inserted_id), {"no": inv_no, "type": payload.type, "total": calc["total"]})
    out = await db.invoices.find_one({"_id": res.inserted_id})
    # v12.37 — fire Auto Transaction Message trigger (no-op when 4-tier gate not satisfied).
    try:
        from txn_messages import fire_event as _txn_fire_event
        _event_key_map = {
            "sale": "sale.created", "purchase": "purchase.created",
            "sale_order": "sale_order.created", "purchase_order": "purchase_order.created",
            "quotation": "quotation.created", "delivery_challan": "delivery_challan.created",
            "credit_note": "credit_note.created", "debit_note": "debit_note.created",
            "sales_return": "sales_return.created", "purchase_return": "purchase_return.created",
        }
        _evt = _event_key_map.get(payload.type)
        if _evt:
            await _txn_fire_event(db, user, _evt, out, request)
    except Exception:
        pass  # NEVER block invoice save
    return _ser(out)


@router.put("/invoices/{inv_id}")
async def update_invoice(inv_id: str, payload: InvoiceIn, request: Request, user=Depends(get_current_user)):
    """Update an existing invoice. Reverses old stock side-effects then applies new ones.
    Preserves invoice_no, type, company_id, created_at, created_by — everything else is replaceable.
    """
    db = request.app.state.db
    existing = await db.invoices.find_one({"_id": _oid(inv_id)})
    if not existing:
        raise HTTPException(404, "Invoice not found")
    if existing.get("status") == "cancelled":
        raise HTTPException(400, "Cancelled invoices cannot be edited. Restore the invoice first.")
    company_id = existing.get("company_id")
    company = await db.companies.find_one({"_id": _oid(company_id)}) if company_id else None
    company_state = (company or {}).get("state", "") if company else ""

    # Force-keep original type (URL routing dictates type already; payload.type is informational)
    inv_type = existing.get("type", payload.type)

    calc = _calc_invoice(payload, company_state=company_state)
    status = payload.status or existing.get("status") or "unpaid"
    if payload.payment_received <= 0:
        status = "unpaid"
    elif payload.payment_received >= calc["total"]:
        status = "paid"
    else:
        status = "partial"
    if existing.get("status") == "cancelled":
        status = "cancelled"

    # ---- Stock side-effect reversal: undo old, then apply new ----
    old_delta = -_stock_delta(inv_type)
    if old_delta != 0:
        for ln in existing.get("lines", []):
            if ln.get("item_id"):
                try:
                    await db.items.update_one(
                        {"_id": _oid(ln["item_id"])},
                        {"$inc": {"current_stock": old_delta * _base_qty(ln)}},
                    )
                except Exception:
                    pass
    new_delta = _stock_delta(inv_type)
    if new_delta != 0:
        for ln in payload.lines:
            if ln.item_id:
                try:
                    await db.items.update_one(
                        {"_id": _oid(ln.item_id)},
                        {"$inc": {"current_stock": new_delta * _base_qty(ln)}},
                    )
                except Exception:
                    pass

    update_doc = {
        "party_id": payload.party_id,
        "party_name": payload.party_name or "",
        "party_gstin": payload.party_gstin or "",
        "party_state": payload.party_state or "",
        "invoice_date": payload.invoice_date or existing.get("invoice_date"),
        "due_date": payload.due_date,
        "notes": payload.notes or "",
        "payment_mode": payload.payment_mode,
        "payment_received": payload.payment_received,
        "status": status,
        "updated_by": user["email"],
        "updated_at": _now_iso(),
        **calc,
    }
    await db.invoices.update_one({"_id": _oid(inv_id)}, {"$set": update_doc})
    await _log(db, user, "update", "invoice", inv_id, {
        "no": existing.get("invoice_no"), "type": inv_type, "total": calc["total"]
    })
    out = await db.invoices.find_one({"_id": _oid(inv_id)})
    # v12.38 — fire on update path (only sends when settings.send_on_update is ON).
    try:
        from txn_messages import fire_event as _txn_fire_event
        _event_key_map = {
            "sale": "sale.created", "purchase": "purchase.created",
            "sale_order": "sale_order.created", "purchase_order": "purchase_order.created",
            "quotation": "quotation.created", "delivery_challan": "delivery_challan.created",
            "credit_note": "credit_note.created", "debit_note": "debit_note.created",
            "sales_return": "sales_return.created", "purchase_return": "purchase_return.created",
        }
        _evt = _event_key_map.get(inv_type)
        if _evt:
            await _txn_fire_event(db, user, _evt, out, request, is_update=True)
    except Exception:
        pass
    return _ser(out)


@router.delete("/invoices/{inv_id}")
async def delete_invoice(inv_id: str, request: Request, user=Depends(require_permission("invoicing.delete"))):
    db = request.app.state.db
    doc = await db.invoices.find_one({"_id": _oid(inv_id)})
    if not doc:
        raise HTTPException(404, "Invoice not found")
    # Reverse stock movement
    delta = -_stock_delta(doc.get("type", ""))
    if delta != 0:
        for ln in doc.get("lines", []):
            if ln.get("item_id"):
                try:
                    await db.items.update_one(
                        {"_id": _oid(ln["item_id"])},
                        {"$inc": {"current_stock": delta * _base_qty(ln)}},
                    )
                except Exception:
                    pass
    # Soft delete to trash
    doc["deleted_at"] = _now_iso()
    doc["deleted_by"] = user["email"]
    await db.invoices_trash.insert_one(doc)
    await db.invoices.delete_one({"_id": _oid(inv_id)})
    await _log(db, user, "delete", "invoice", inv_id, {"no": doc.get("invoice_no")})
    return {"ok": True}


@router.post("/invoices/bulk-delete")
async def bulk_delete_invoices(payload: "_BulkIds", request: Request, user=Depends(require_permission("invoicing.delete"))):
    """Soft-delete many invoices in one shot. Stock reversals are applied per row.
    Same shape as `/items/bulk-delete` and `/parties/bulk-delete`."""
    db = request.app.state.db
    if not payload.ids:
        raise HTTPException(422, "ids required")
    oids = [_oid(i) for i in payload.ids]
    docs = await db.invoices.find({"_id": {"$in": oids}}).to_list(50000)
    if not docs:
        return {"moved": 0, "skipped": len(payload.ids)}
    now = _now_iso()
    for d in docs:
        # Reverse stock movement before tombstoning the invoice
        delta = -_stock_delta(d.get("type", ""))
        if delta != 0:
            for ln in d.get("lines", []):
                if ln.get("item_id"):
                    try:
                        await db.items.update_one(
                            {"_id": _oid(ln["item_id"])},
                            {"$inc": {"current_stock": delta * _base_qty(ln)}},
                        )
                    except Exception:
                        pass
        d["deleted_at"] = now
        d["deleted_by"] = user["email"]
    await db.invoices_trash.insert_many(docs)
    res = await db.invoices.delete_many({"_id": {"$in": [d["_id"] for d in docs]}})
    await _log(db, user, "bulk_delete", "invoices", None, {"count": res.deleted_count})
    return {"moved": res.deleted_count, "skipped": len(payload.ids) - res.deleted_count}


@router.post("/invoices/{inv_id}/restore")
async def restore_invoice(inv_id: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    doc = await db.invoices_trash.find_one({"_id": _oid(inv_id)})
    if not doc:
        raise HTTPException(404, "Not in trash")
    doc.pop("deleted_at", None)
    doc.pop("deleted_by", None)
    await db.invoices.insert_one(doc)
    await db.invoices_trash.delete_one({"_id": _oid(inv_id)})
    # Re-apply original stock side-effects (delete had reversed them)
    delta = _stock_delta(doc.get("type", ""))
    if delta != 0:
        for ln in doc.get("lines", []):
            if ln.get("item_id"):
                try:
                    await db.items.update_one(
                        {"_id": _oid(ln["item_id"])},
                        {"$inc": {"current_stock": delta * _base_qty(ln)}},
                    )
                except Exception:
                    pass
    await _log(db, user, "restore", "invoice", inv_id)
    return _ser(doc)


@router.get("/invoices-trash")
async def list_trash(request: Request, company_id: Optional[str] = None, user=Depends(require_admin)):
    db = request.app.state.db
    q = {}
    if company_id:
        q["company_id"] = company_id
    docs = await db.invoices_trash.find(q).sort("deleted_at", -1).to_list(500)
    return [_ser(d) for d in docs]


@router.delete("/invoices-trash/{inv_id}")
async def purge_invoice(inv_id: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    res = await db.invoices_trash.delete_one({"_id": _oid(inv_id)})
    if res.deleted_count == 0:
        raise HTTPException(404, "Not in trash")
    await _log(db, user, "purge", "invoice", inv_id)
    return {"ok": True}


@router.get("/trash/summary")
async def trash_summary(request: Request, company_id: Optional[str] = None, user=Depends(require_admin)):
    """Counts in all trash collections for the active company."""
    db = request.app.state.db
    q = {"company_id": company_id} if company_id else {}
    return {
        "invoices": await db.invoices_trash.count_documents(q),
        "parties": await db.parties_trash.count_documents(q),
        "items": await db.items_trash.count_documents(q),
        "expenses": await db.expenses_trash.count_documents(q),
    }


@router.post("/trash/empty")
async def empty_trash(request: Request, company_id: Optional[str] = None, kind: Optional[str] = None, user=Depends(require_admin)):
    """Permanently delete everything in trash (optionally one kind only)."""
    db = request.app.state.db
    q = {"company_id": company_id} if company_id else {}
    allowed = {"invoices", "parties", "items", "expenses"}
    if kind and kind not in allowed:
        raise HTTPException(400, f"Invalid kind. Allowed: {sorted(allowed)}")
    kinds = [kind] if kind else list(allowed)
    deleted = {}
    for k in kinds:
        coll = getattr(db, f"{k}_trash")
        res = await coll.delete_many(q)
        deleted[k] = res.deleted_count
    await _log(db, user, "purge", "trash_all", None, deleted)
    return {"deleted": deleted}


# ----- Quotation → Invoice conversion -----
@router.post("/invoices/{inv_id}/convert-to-invoice")
async def convert_quotation_to_invoice(inv_id: str, request: Request, user=Depends(get_current_user)):
    """One-click: copy a quotation/proforma/sale-order into a Sale Invoice."""
    db = request.app.state.db
    src = await db.invoices.find_one({"_id": _oid(inv_id)})
    if not src:
        raise HTTPException(404, "Source document not found")
    if src.get("type") not in ("quotation", "challan", "sale_order", "proforma"):
        raise HTTPException(400, "Only quotations / challans / sale orders / proforma can be converted to invoice")
    company_id = src.get("company_id")
    inv_no = await _next_invoice_no(db, company_id, "sale")
    new_doc = {
        **{k: v for k, v in src.items() if k != "_id"},
        "type": "sale",
        "invoice_no": inv_no,
        "invoice_date": _now_iso()[:10],
        "status": "unpaid",
        "payment_received": 0.0,
        "created_by": user["email"],
        "created_at": _now_iso(),
        "converted_from": str(src["_id"]),
    }
    res = await db.invoices.insert_one(new_doc)
    # Apply stock side-effect (sale = decrement)
    for ln in new_doc.get("lines", []):
        if ln.get("item_id"):
            try:
                await db.items.update_one(
                    {"_id": _oid(ln["item_id"])},
                    {"$inc": {"current_stock": -_base_qty(ln)}},
                )
            except Exception:
                pass
    await _log(db, user, "convert", "invoice", str(res.inserted_id),
               {"from": src.get("invoice_no"), "to": inv_no})
    out = await db.invoices.find_one({"_id": res.inserted_id})
    return _ser(out)


# ----- E-Invoice / E-Way bill JSON (IRP-ready payload) -----
@router.get("/invoices/{inv_id}/einvoice-json")
async def einvoice_json(inv_id: str, request: Request, user=Depends(get_current_user)):
    db = request.app.state.db
    inv = await db.invoices.find_one({"_id": _oid(inv_id)})
    if not inv:
        raise HTTPException(404, "Invoice not found")
    company = await db.companies.find_one({"_id": _oid(inv.get("company_id"))}) if inv.get("company_id") else None
    payload = {
        "Version": "1.1",
        "TranDtls": {"TaxSch": "GST", "SupTyp": "B2B" if inv.get("party_gstin") else "B2C"},
        "DocDtls": {"Typ": "INV", "No": inv.get("invoice_no"), "Dt": inv.get("invoice_date")},
        "SellerDtls": {
            "Gstin": (company or {}).get("gstin", ""),
            "LglNm": (company or {}).get("legal_name") or (company or {}).get("name", ""),
            "Addr1": (company or {}).get("address", ""),
            "Loc": (company or {}).get("state", ""),
            "Pin": "",
            "Stcd": (company or {}).get("state", ""),
        },
        "BuyerDtls": {
            "Gstin": inv.get("party_gstin", ""),
            "LglNm": inv.get("party_name", ""),
            "Pos": inv.get("party_state") or (company or {}).get("state", ""),
            "Addr1": "",
            "Loc": inv.get("party_state", ""),
            "Pin": "",
            "Stcd": inv.get("party_state", ""),
        },
        "ItemList": [
            {
                "SlNo": str(i + 1),
                "PrdDesc": ln.get("name"),
                "HsnCd": ln.get("hsn") or "",
                "Qty": ln.get("qty", 0),
                "Unit": ln.get("unit", "PCS"),
                "UnitPrice": ln.get("rate", 0),
                "TotAmt": round(float(ln.get("qty", 0)) * float(ln.get("rate", 0)), 2),
                "Discount": round(float(ln.get("qty", 0)) * float(ln.get("rate", 0)) * float(ln.get("discount", 0)) / 100, 2),
                "AssAmt": ln.get("taxable", 0),
                "GstRt": ln.get("gst_rate", 0),
                "IgstAmt": ln.get("gst_amount", 0) if inv.get("interstate") else 0,
                "CgstAmt": 0 if inv.get("interstate") else round(float(ln.get("gst_amount", 0)) / 2, 2),
                "SgstAmt": 0 if inv.get("interstate") else round(float(ln.get("gst_amount", 0)) / 2, 2),
                "TotItemVal": ln.get("total", 0),
            }
            for i, ln in enumerate(inv.get("lines", []))
        ],
        "ValDtls": {
            "AssVal": inv.get("subtotal", 0),
            "CgstVal": inv.get("cgst", 0),
            "SgstVal": inv.get("sgst", 0),
            "IgstVal": inv.get("igst", 0),
            "Discount": inv.get("extra_discount", 0),
            "RndOffAmt": inv.get("round_off", 0),
            "TotInvVal": inv.get("total", 0),
        },
    }
    return payload


@router.get("/invoices/{inv_id}/eway-json")
async def eway_json(inv_id: str, request: Request, user=Depends(get_current_user)):
    db = request.app.state.db
    inv = await db.invoices.find_one({"_id": _oid(inv_id)})
    if not inv:
        raise HTTPException(404, "Invoice not found")
    company = await db.companies.find_one({"_id": _oid(inv.get("company_id"))}) if inv.get("company_id") else None
    payload = {
        "supplyType": "O",
        "subSupplyType": "1",
        "docType": "INV",
        "docNo": inv.get("invoice_no"),
        "docDate": inv.get("invoice_date"),
        "fromGstin": (company or {}).get("gstin", ""),
        "fromTrdName": (company or {}).get("name", ""),
        "fromAddr1": (company or {}).get("address", ""),
        "fromPlace": (company or {}).get("state", ""),
        "fromStateCode": (company or {}).get("state", ""),
        "toGstin": inv.get("party_gstin") or "URP",
        "toTrdName": inv.get("party_name", ""),
        "toAddr1": "",
        "toPlace": inv.get("party_state", ""),
        "toStateCode": inv.get("party_state", ""),
        "totalValue": inv.get("subtotal", 0),
        "cgstValue": inv.get("cgst", 0),
        "sgstValue": inv.get("sgst", 0),
        "igstValue": inv.get("igst", 0),
        "cessValue": 0,
        "totInvValue": inv.get("total", 0),
        "transMode": "1",
        "transDistance": "0",
        "itemList": [
            {
                "productName": ln.get("name"),
                "hsnCode": ln.get("hsn", ""),
                "quantity": ln.get("qty", 0),
                "qtyUnit": ln.get("unit", "PCS"),
                "taxableAmount": ln.get("taxable", 0),
                "cgstRate": (ln.get("gst_rate", 0) / 2) if not inv.get("interstate") else 0,
                "sgstRate": (ln.get("gst_rate", 0) / 2) if not inv.get("interstate") else 0,
                "igstRate": ln.get("gst_rate", 0) if inv.get("interstate") else 0,
            }
            for ln in inv.get("lines", [])
        ],
    }
    return payload


# ----- Expenses -----
@router.get("/expenses")
async def list_expenses(request: Request, company_id: Optional[str] = None, user=Depends(get_current_user)):
    db = request.app.state.db
    q = {}
    if company_id:
        q["company_id"] = company_id
    docs = await db.expenses.find(q).sort("date", -1).to_list(2000)
    return [_ser(d) for d in docs]


@router.post("/expenses")
async def create_expense(payload: ExpenseIn, request: Request, company_id: str = Query(...), user=Depends(require_admin)):
    db = request.app.state.db
    doc = payload.model_dump()
    doc["company_id"] = company_id
    doc["date"] = doc.get("date") or _now_iso()[:10]
    doc["created_at"] = _now_iso()
    # v12.41 — Smart Serial: same engine as invoices/payments. If user left
    # expense_no blank → auto-fill next number from the per-user counter for
    # the (company, type="expense") prefix. If user typed one → validate it's
    # unique for this user AND bump the counter forward (Smart Continue).
    user_email = user.get("email", "")
    try:
        manual_no = (payload.expense_no or "").strip()
        if manual_no:
            # Duplicate check per (company, type, expense_no, created_by)
            dup = await db.expenses.find_one({
                "company_id": company_id,
                "expense_no": manual_no,
                "created_by": user_email,
            })
            if dup:
                raise HTTPException(400, f"Expense number '{manual_no}' already exists for you. Pick a different number.")
            # Bump counter so next auto continues from manual_seq + 1
            from txn_prefixes import bump_user_counter_for_manual
            _pid, _fy = await bump_user_counter_for_manual(
                db, company_id=company_id, txn_type="expense",
                user_email=user_email, manual_invoice_no=manual_no,
            )
            doc["expense_no"] = manual_no
            doc["prefix_id"] = _pid
            doc["financial_year"] = _fy
        else:
            # Atomic auto-resolve
            from txn_prefixes import resolve_next_invoice_no_per_user
            auto_no, prefix_id, fy = await resolve_next_invoice_no_per_user(
                db, company_id=company_id, txn_type="expense", user_email=user_email,
            )
            doc["expense_no"] = auto_no
            doc["prefix_id"] = prefix_id
            doc["financial_year"] = fy
    except HTTPException:
        raise
    except Exception as _ex:
        # Backward-compat: if numbering engine fails for any reason, do NOT
        # block the save. Legacy expenses just won't have expense_no.
        pass
    doc["created_by"] = user_email
    res = await db.expenses.insert_one(doc)
    await _log(db, user, "create", "expense", str(res.inserted_id), {"amount": payload.amount, "category": payload.category, "expense_no": doc.get("expense_no")})
    out = await db.expenses.find_one({"_id": res.inserted_id})
    # v12.37 — fire Auto Transaction Message trigger (no-op when 4-tier gate not satisfied).
    try:
        from txn_messages import fire_event as _txn_fire_event
        await _txn_fire_event(db, user, "expense.created", out, request)
    except Exception:
        pass  # NEVER block expense save
    return _ser(out)


# v12.34 — GET single expense (needed by full-page edit screen)
@router.get("/expenses/{eid}")
async def get_expense(eid: str, request: Request, user=Depends(get_current_user)):
    db = request.app.state.db
    doc = await db.expenses.find_one({"_id": _oid(eid)})
    if not doc:
        raise HTTPException(404, "Expense not found")
    return _ser(doc)


# v12.34 — PUT to update an existing expense (Vyapar-style edit flow)
@router.put("/expenses/{eid}")
async def update_expense(eid: str, payload: ExpenseIn, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    existing = await db.expenses.find_one({"_id": _oid(eid)})
    if not existing:
        raise HTTPException(404, "Expense not found")
    update = payload.model_dump()
    update["updated_at"] = _now_iso()
    update["updated_by"] = user.get("email")
    # Preserve immutable fields
    update.pop("created_at", None)
    update.pop("company_id", None)
    await db.expenses.update_one({"_id": _oid(eid)}, {"$set": update})
    await _log(db, user, "update", "expense", eid, {"amount": payload.amount, "category": payload.category})
    out = await db.expenses.find_one({"_id": _oid(eid)})
    # v12.38 — fire on update path (only sends when settings.send_on_update is ON).
    try:
        from txn_messages import fire_event as _txn_fire_event
        await _txn_fire_event(db, user, "expense.created", out, request, is_update=True)
    except Exception:
        pass
    return _ser(out)


@router.delete("/expenses/{eid}")
async def delete_expense(eid: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    doc = await db.expenses.find_one({"_id": _oid(eid)})
    if not doc:
        raise HTTPException(404, "Expense not found")
    doc["deleted_at"] = _now_iso()
    doc["deleted_by"] = user["email"]
    await db.expenses_trash.insert_one(doc)
    await db.expenses.delete_one({"_id": _oid(eid)})
    await _log(db, user, "delete", "expense", eid)
    return {"ok": True}


@router.post("/expenses/{eid}/restore")
async def restore_expense(eid: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    doc = await db.expenses_trash.find_one({"_id": _oid(eid)})
    if not doc:
        raise HTTPException(404, "Not in trash")
    doc.pop("deleted_at", None)
    doc.pop("deleted_by", None)
    await db.expenses.insert_one(doc)
    await db.expenses_trash.delete_one({"_id": _oid(eid)})
    await _log(db, user, "restore", "expense", eid)
    return _ser(doc)


@router.delete("/expenses-trash/{eid}")
async def purge_expense(eid: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    res = await db.expenses_trash.delete_one({"_id": _oid(eid)})
    if res.deleted_count == 0:
        raise HTTPException(404, "Not in trash")
    await _log(db, user, "purge", "expense", eid)
    return {"ok": True}


@router.get("/expenses-trash")
async def list_expenses_trash(request: Request, company_id: Optional[str] = None, user=Depends(require_admin)):
    db = request.app.state.db
    q = {}
    if company_id:
        q["company_id"] = company_id
    docs = await db.expenses_trash.find(q).sort("deleted_at", -1).to_list(2000)
    return [_ser(d) for d in docs]


# ----- Dashboard -----
@router.get("/dashboard/stats")
async def dashboard_stats(request: Request, company_id: Optional[str] = None, user=Depends(get_current_user)):
    db = request.app.state.db
    q_base = {"company_id": company_id} if company_id else {}

    sales = await db.invoices.find(
        {**q_base, "type": "sale"},
        {"total": 1, "payment_received": 1, "invoice_date": 1, "created_at": 1, "lines": 1, "invoice_no": 1, "party_name": 1, "status": 1, "type": 1, "company_id": 1},
    ).to_list(5000)
    purchases = await db.invoices.find(
        {**q_base, "type": "purchase"},
        {"total": 1, "payment_received": 1},
    ).to_list(5000)
    expenses = await db.expenses.find(q_base, {"amount": 1}).to_list(5000)
    items = await db.items.find(
        q_base,
        {"name": 1, "current_stock": 1, "purchase_price": 1, "low_stock_threshold": 1},
    ).to_list(5000)
    parties = await db.parties.find(q_base, {"_id": 1}).to_list(5000)

    total_sales = sum(float(s.get("total", 0)) for s in sales)
    total_purchases = sum(float(p.get("total", 0)) for p in purchases)
    total_expenses = sum(float(e.get("amount", 0)) for e in expenses)
    receivable = sum(max(0.0, float(s.get("total", 0)) - float(s.get("payment_received", 0))) for s in sales)
    payable = sum(max(0.0, float(p.get("total", 0)) - float(p.get("payment_received", 0))) for p in purchases)

    stock_value = sum(float(it.get("current_stock", 0)) * float(it.get("purchase_price", 0)) for it in items)
    low_stock = [
        {"id": str(it["_id"]), "name": it.get("name"), "current_stock": it.get("current_stock", 0), "threshold": it.get("low_stock_threshold", 0)}
        for it in items if float(it.get("current_stock", 0)) <= float(it.get("low_stock_threshold", 0))
    ][:10]

    # Sales by month (last 6 months)
    from collections import defaultdict
    monthly = defaultdict(float)
    for sale in sales:
        dt = sale.get("invoice_date") or sale.get("created_at", "")
        key = dt[:7] if dt else ""
        if key:
            monthly[key] += float(sale.get("total", 0))
    sales_series = sorted([{"month": k, "amount": round(v, 2)} for k, v in monthly.items()], key=lambda x: x["month"])[-6:]

    # Top items by quantity sold
    item_qty = defaultdict(float)
    item_name = {}
    for sale in sales:
        for ln in sale.get("lines", []):
            iid = ln.get("item_id") or ln.get("name")
            item_qty[iid] += float(ln.get("qty", 0))
            item_name[iid] = ln.get("name", "Unknown")
    top_items = sorted(
        [{"name": item_name.get(k, "Unknown"), "qty": round(v, 2)} for k, v in item_qty.items()],
        key=lambda x: -x["qty"]
    )[:5]

    recent_sales = sorted(sales, key=lambda x: x.get("created_at", ""), reverse=True)[:5]

    return {
        "total_sales": round(total_sales, 2),
        "total_purchases": round(total_purchases, 2),
        "total_expenses": round(total_expenses, 2),
        "receivable": round(receivable, 2),
        "payable": round(payable, 2),
        "stock_value": round(stock_value, 2),
        "profit": round(total_sales - total_purchases - total_expenses, 2),
        "counts": {
            "items": len(items),
            "parties": len(parties),
            "sales": len(sales),
            "purchases": len(purchases),
        },
        "low_stock": low_stock,
        "sales_series": sales_series,
        "top_items": top_items,
        "recent_sales": [_ser(sale) for sale in recent_sales],
    }


# ----- Reports -----
@router.get("/reports/gst")
async def gst_report(request: Request, company_id: Optional[str] = None, user=Depends(get_current_user)):
    db = request.app.state.db
    q = {"type": "sale"}
    if company_id:
        q["company_id"] = company_id
    sales = await db.invoices.find(q).to_list(5000)
    by_rate = {}
    for s in sales:
        for ln in s.get("lines", []):
            rate = float(ln.get("gst_rate", 0))
            taxable = float(ln.get("taxable", 0))
            gst = float(ln.get("gst_amount", 0))
            agg = by_rate.setdefault(rate, {"rate": rate, "taxable": 0, "gst": 0, "cgst": 0, "sgst": 0})
            agg["taxable"] += taxable
            agg["gst"] += gst
            agg["cgst"] += gst / 2
            agg["sgst"] += gst / 2
    rows = sorted(
        [{k: (round(v, 2) if isinstance(v, (int, float)) else v) for k, v in r.items()} for r in by_rate.values()],
        key=lambda x: x["rate"]
    )
    return {"rows": rows}


@router.get("/reports/sales-summary")
async def sales_summary(request: Request, company_id: Optional[str] = None, user=Depends(get_current_user)):
    db = request.app.state.db
    q = {"type": "sale"}
    if company_id:
        q["company_id"] = company_id
    sales = await db.invoices.find(q).to_list(5000)
    from collections import defaultdict
    by_party = defaultdict(lambda: {"name": "", "total": 0, "count": 0})
    for s in sales:
        name = s.get("party_name") or "Walk-in"
        by_party[name]["name"] = name
        by_party[name]["total"] += float(s.get("total", 0))
        by_party[name]["count"] += 1
    rows = sorted(by_party.values(), key=lambda x: -x["total"])
    return {"rows": [{"name": r["name"], "total": round(r["total"], 2), "count": r["count"]} for r in rows]}


@router.get("/reports/stock")
async def stock_report(request: Request, company_id: Optional[str] = None, user=Depends(get_current_user)):
    db = request.app.state.db
    q = {"company_id": company_id} if company_id else {}
    items = await db.items.find(q).to_list(5000)
    rows = [{
        "name": it.get("name"),
        "code": it.get("code", ""),
        "unit": it.get("unit"),
        "current_stock": it.get("current_stock", 0),
        "purchase_price": it.get("purchase_price", 0),
        "value": round(float(it.get("current_stock", 0)) * float(it.get("purchase_price", 0)), 2),
        "low": float(it.get("current_stock", 0)) <= float(it.get("low_stock_threshold", 0)),
    } for it in items]
    return {"rows": rows}


# ----- Accounting reports: Cashbook, P&L, Balance Sheet -----
@router.get("/reports/cashbook")
async def cashbook_report(request: Request, company_id: Optional[str] = None, user=Depends(get_current_user)):
    """Combined cash + bank book — all inflows (sales receipts) and outflows (purchase payments + expenses)."""
    db = request.app.state.db
    q_inv = {"type": {"$in": ["sale", "purchase"]}}
    q_exp = {}
    if company_id:
        q_inv["company_id"] = company_id
        q_exp["company_id"] = company_id

    invoices = await db.invoices.find(
        q_inv, {"type": 1, "invoice_no": 1, "invoice_date": 1, "party_name": 1, "payment_received": 1, "payment_mode": 1, "total": 1},
    ).to_list(5000)
    expenses = await db.expenses.find(q_exp).to_list(5000)

    rows = []
    for inv in invoices:
        recv = float(inv.get("payment_received", 0) or 0)
        if recv <= 0:
            continue
        rows.append({
            "date": inv.get("invoice_date"),
            "ref": inv.get("invoice_no"),
            "particulars": f"{inv.get('party_name', 'Walk-in')}",
            "mode": inv.get("payment_mode", "Cash"),
            "inflow": recv if inv.get("type") == "sale" else 0,
            "outflow": recv if inv.get("type") == "purchase" else 0,
        })
    for ex in expenses:
        rows.append({
            "date": ex.get("date"),
            "ref": f"EXP-{str(ex.get('_id'))[-6:]}",
            "particulars": f"{ex.get('category', 'Expense')} — {ex.get('vendor', '')}".strip(" —"),
            "mode": ex.get("payment_mode", "Cash"),
            "inflow": 0,
            "outflow": float(ex.get("amount", 0) or 0),
        })
    rows.sort(key=lambda r: r.get("date") or "", reverse=True)
    total_in = sum(r["inflow"] for r in rows)
    total_out = sum(r["outflow"] for r in rows)
    return {
        "rows": [{**r, "inflow": round(r["inflow"], 2), "outflow": round(r["outflow"], 2)} for r in rows],
        "total_inflow": round(total_in, 2),
        "total_outflow": round(total_out, 2),
        "net_balance": round(total_in - total_out, 2),
    }


@router.get("/reports/pl")
async def profit_loss_report(request: Request, company_id: Optional[str] = None, user=Depends(get_current_user)):
    """Profit & Loss statement — revenue, COGS, expenses, net profit."""
    db = request.app.state.db
    q_base = {"company_id": company_id} if company_id else {}

    sales = await db.invoices.find({**q_base, "type": "sale"}, {"subtotal": 1, "total_gst": 1, "total": 1}).to_list(10000)
    purchases = await db.invoices.find({**q_base, "type": "purchase"}, {"subtotal": 1, "total_gst": 1, "total": 1}).to_list(10000)
    expenses = await db.expenses.find(q_base, {"category": 1, "amount": 1}).to_list(10000)

    rev = sum(float(s.get("subtotal", 0)) for s in sales)
    rev_gross = sum(float(s.get("total", 0)) for s in sales)
    purchase_cost = sum(float(p.get("subtotal", 0)) for p in purchases)
    gst_collected = sum(float(s.get("total_gst", 0)) for s in sales)
    gst_paid = sum(float(p.get("total_gst", 0)) for p in purchases)

    from collections import defaultdict
    exp_by_cat = defaultdict(float)
    for ex in expenses:
        exp_by_cat[ex.get("category", "Other")] += float(ex.get("amount", 0))
    expense_rows = sorted(
        [{"category": k, "amount": round(v, 2)} for k, v in exp_by_cat.items()],
        key=lambda x: -x["amount"],
    )
    total_expenses = sum(r["amount"] for r in expense_rows)

    gross_profit = rev - purchase_cost
    net_profit = gross_profit - total_expenses

    return {
        "revenue": round(rev, 2),
        "revenue_with_tax": round(rev_gross, 2),
        "cogs": round(purchase_cost, 2),
        "gross_profit": round(gross_profit, 2),
        "expenses": expense_rows,
        "total_expenses": round(total_expenses, 2),
        "net_profit": round(net_profit, 2),
        "gst_collected": round(gst_collected, 2),
        "gst_paid": round(gst_paid, 2),
        "gst_payable": round(max(0.0, gst_collected - gst_paid), 2),
    }


@router.get("/reports/balance-sheet")
async def balance_sheet_report(request: Request, company_id: Optional[str] = None, user=Depends(get_current_user)):
    """Simple balance sheet snapshot — assets vs liabilities."""
    db = request.app.state.db
    q_base = {"company_id": company_id} if company_id else {}

    sales = await db.invoices.find({**q_base, "type": "sale"}, {"total": 1, "payment_received": 1}).to_list(10000)
    purchases = await db.invoices.find({**q_base, "type": "purchase"}, {"total": 1, "payment_received": 1}).to_list(10000)
    expenses = await db.expenses.find(q_base, {"amount": 1, "payment_mode": 1}).to_list(10000)
    items = await db.items.find(q_base, {"current_stock": 1, "purchase_price": 1}).to_list(10000)

    receivables = sum(max(0.0, float(s.get("total", 0)) - float(s.get("payment_received", 0))) for s in sales)
    payables = sum(max(0.0, float(p.get("total", 0)) - float(p.get("payment_received", 0))) for p in purchases)
    cash_in = sum(float(s.get("payment_received", 0)) for s in sales)
    cash_out = sum(float(p.get("payment_received", 0)) for p in purchases) + sum(float(e.get("amount", 0)) for e in expenses)
    cash_balance = cash_in - cash_out
    stock_value = sum(float(it.get("current_stock", 0)) * float(it.get("purchase_price", 0)) for it in items)

    total_assets = receivables + max(0.0, cash_balance) + stock_value
    total_liabilities = payables + max(0.0, -cash_balance)
    equity = total_assets - total_liabilities

    return {
        "assets": [
            {"label": "Cash & Bank", "amount": round(max(0.0, cash_balance), 2)},
            {"label": "Accounts Receivable", "amount": round(receivables, 2)},
            {"label": "Inventory at Cost", "amount": round(stock_value, 2)},
        ],
        "liabilities": [
            {"label": "Accounts Payable", "amount": round(payables, 2)},
            {"label": "Bank Overdraft", "amount": round(max(0.0, -cash_balance), 2)},
        ],
        "total_assets": round(total_assets, 2),
        "total_liabilities": round(total_liabilities, 2),
        "owners_equity": round(equity, 2),
    }


# ----- Users (admin only) -----
@router.get("/users")
async def list_users(request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    docs = await db.users.find({}, {"password_hash": 0}).to_list(500)
    return [_ser(d) for d in docs]


@router.post("/users")
async def create_user(payload: UserIn, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    email = payload.email.lower()
    if await db.users.find_one({"email": email}):
        raise HTTPException(400, "Email already exists")
    doc = {
        "email": email,
        "name": payload.name,
        "role": payload.role,
        "phone": payload.phone or "",
        "is_active": True if payload.is_active is None else bool(payload.is_active),
        "password_hash": hash_password(payload.password),
        "created_at": _now_iso(),
    }
    res = await db.users.insert_one(doc)
    await _log(db, user, "create", "user", str(res.inserted_id), {"email": email, "role": payload.role})
    doc["id"] = str(res.inserted_id)
    doc.pop("password_hash", None)
    doc.pop("_id", None)
    return doc


@router.put("/users/{uid}")
async def update_user(uid: str, payload: UserUpdate, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    upd = {}
    if payload.name is not None:
        upd["name"] = payload.name
    if payload.role is not None:
        upd["role"] = payload.role
    if payload.password:
        upd["password_hash"] = hash_password(payload.password)
    if payload.phone is not None:
        upd["phone"] = payload.phone
    if payload.is_active is not None:
        upd["is_active"] = bool(payload.is_active)
    if upd:
        await db.users.update_one({"_id": _oid(uid)}, {"$set": upd})
    await _log(db, user, "update", "user", uid)
    out = await db.users.find_one({"_id": _oid(uid)}, {"password_hash": 0})
    return _ser(out)


@router.delete("/users/{uid}")
async def delete_user(uid: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    if str(uid) == str(user["id"]):
        raise HTTPException(400, "Cannot delete self")
    await db.users.delete_one({"_id": _oid(uid)})
    await _log(db, user, "delete", "user", uid)
    return {"ok": True}


# ----- Activity Logs -----
@router.get("/activity-logs")
async def list_activity(request: Request, limit: int = 200, user=Depends(require_admin)):
    db = request.app.state.db
    docs = await db.activity_logs.find().sort("timestamp", -1).to_list(limit)
    return [_ser(d) for d in docs]
