from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import case, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload, selectinload

from app.api.deps import AuthSession, get_auth_session
from app.db.session import get_db
from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial
from app.models.change_event import EditorChangeEvent
from app.schemas.editor import (
    EditorChangeEventRead,
    EditorIngredientCreate,
    EditorIngredientRow,
    EditorIngredientUpdate,
    EditorMixFormulaRead,
    EditorMixCreate,
    EditorMixFormulaReplace,
    EditorMixIngredientCreate,
    EditorMixIngredientUpdate,
    EditorMixRow,
    EditorMixUpdate,
    EditorProductFormulaRead,
    EditorProductIngredientCreate,
    EditorProductIngredientUpdate,
    EditorProductRow,
    EditorProductUpdate,
    EditorResolvedMixFormula,
    EditorResolvedMixIngredient,
)
from app.services.change_log import (
    ENTITY_INGREDIENT,
    ENTITY_MIX,
    diff_fields,
    list_changes,
    record_change,
)
from app.services.client_access_service import has_access_level
from app.services.costing_engine import calculate_raw_material_cost, get_active_price
from app.services.mix_calculator_service import resolve_editor_mix_formula, resolve_representative_product

router = APIRouter(prefix="/api/editor", tags=["editor"])


def _serialize_row(product: Product) -> dict:
    return {
        "id": product.id,
        "tenant_id": product.tenant_id,
        "client_name": product.client_name,
        "item_id": product.item_id,
        "name": product.name,
        "mix_id": product.mix_id,
        "mix_client_name": product.mix.client_name if product.mix else "",
        "mix_name": product.mix.name if product.mix else "",
        "sale_type": product.sale_type,
        "unit_of_measure": product.unit_of_measure,
        "visible": product.visible,
        "product_notes": product.notes,
        "mix_notes": product.mix.notes if product.mix else None,
    }


def _serialize_product_formula(product: Product) -> dict:
    ingredients = [
        {
            "id": ingredient.id,
            "raw_material_id": ingredient.raw_material_id,
            "raw_material_name": ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}",
            "quantity_kg": ingredient.quantity_kg,
            "sort_order": ingredient.sort_order,
            "notes": ingredient.notes,
        }
        for ingredient in sorted(product.ingredients, key=lambda item: (item.sort_order, item.raw_material.name if item.raw_material else ""))
    ]
    return {
        "id": product.id,
        "tenant_id": product.tenant_id,
        "client_name": product.client_name,
        "name": product.name,
        "mix_id": product.mix_id,
        "mix_name": product.mix.name if product.mix else "",
        "ingredients": ingredients,
        "total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4),
    }


def _serialize_mix_row(mix: Mix, *, visible_count: int, product_count: int) -> dict:
    # Status is product-driven once a mix has products (Active = at least one
    # visible product). A mix with no products yet has nothing to fan out to, so
    # it falls back to its own `status` column — that's what lets a brand-new
    # mix read as Active instead of being stuck Inactive and hidden.
    visible = visible_count > 0 if product_count > 0 else mix.status == "active"
    return {
        "id": mix.id,
        "tenant_id": mix.tenant_id,
        "client_name": mix.client_name,
        "name": mix.name,
        "visible": visible,
        "product_count": product_count,
        "visible_product_count": visible_count,
        "notes": mix.notes,
    }


def _mix_product_counts(db: Session, tenant_id: str) -> dict[int, tuple[int, int]]:
    """Per-mix (total products, visible products) used to drive the Status column."""
    rows = db.execute(
        select(
            Product.mix_id,
            func.count(),
            func.sum(case((Product.visible, 1), else_=0)),
        )
        .where(Product.tenant_id == tenant_id)
        .group_by(Product.mix_id)
    ).all()
    return {mix_id: (int(total), int(visible or 0)) for mix_id, total, visible in rows}


def _serialize_mix_formula(mix: Mix) -> dict:
    ingredients = [
        {
            "id": ingredient.id,
            "raw_material_id": ingredient.raw_material_id,
            "raw_material_name": ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}",
            "quantity_kg": ingredient.quantity_kg,
            "notes": ingredient.notes,
        }
        for ingredient in sorted(
            mix.ingredients,
            key=lambda item: item.raw_material.name if item.raw_material else "",
        )
    ]
    return {
        "id": mix.id,
        "tenant_id": mix.tenant_id,
        "client_name": mix.client_name,
        "name": mix.name,
        "ingredients": ingredients,
        "total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4),
    }


def _serialize_change_event(event: EditorChangeEvent) -> dict:
    return {
        "id": event.id,
        "entity_type": event.entity_type,
        "entity_id": event.entity_id,
        "action": event.action,
        "actor_name": event.actor_name,
        "actor_email": event.actor_email,
        "actor_role": event.actor_role,
        "summary": event.summary,
        "changes": event.changes or [],
        "created_at": event.created_at,
    }


def _format_kg(value: float) -> str:
    text = f"{value:.4f}".rstrip("0").rstrip(".")
    return f"{text or '0'} kg"


def _formula_deltas(
    before: list[EditorResolvedMixIngredient] | list,
    after: list[EditorResolvedMixIngredient] | list,
) -> list[dict]:
    """Per-ingredient before/after deltas between two resolved formulas."""
    before_map = {row.raw_material_name: row.quantity_kg for row in before}
    after_map = {row.raw_material_name: row.quantity_kg for row in after}
    deltas: list[dict] = []
    for name in sorted(set(before_map) | set(after_map)):
        old = before_map.get(name)
        new = after_map.get(name)
        if old == new:
            continue
        deltas.append(
            {
                "field": name,
                "label": name,
                "before": _format_kg(old) if old is not None else None,
                "after": _format_kg(new) if new is not None else None,
            }
        )
    return deltas


def _load_editor_mix_formula(db: Session, *, mix_id: int, tenant_id: str) -> Mix | None:
    return db.scalar(
        select(Mix)
        .where(Mix.id == mix_id, Mix.tenant_id == tenant_id)
        .options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
    )


def _load_editor_product_formula(db: Session, *, product_id: int, tenant_id: str) -> Product | None:
    return db.scalar(
        select(Product)
        .where(Product.id == product_id, Product.tenant_id == tenant_id)
        .options(
            joinedload(Product.mix),
            selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
        )
    )


def _require_editor_session(
    session: AuthSession = Depends(get_auth_session),
    db: Session = Depends(get_db),
) -> AuthSession:
    if session.role == "internal":
        permissions = session.module_permissions or {}
        if not has_access_level(permissions.get("client_access"), "manage"):
            raise HTTPException(status_code=403, detail="Lean access is required")
        if not has_access_level(permissions.get("products"), "edit"):
            raise HTTPException(status_code=403, detail="products edit access is required")
        if not has_access_level(permissions.get("mix_master"), "edit"):
            raise HTTPException(status_code=403, detail="mix_master edit access is required")
        if not session.tenant_id:
            raise HTTPException(status_code=403, detail="Internal user context is missing")
        return session

    raise HTTPException(status_code=403, detail="Lean access is required")


@router.get("/products", response_model=list[EditorProductRow])
def list_editor_products(
    q: str | None = Query(default=None, max_length=255),
    client_name: str | None = Query(default=None, max_length=255),
    limit: int = Query(default=500, ge=1, le=1000),
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    statement = (
        select(Product)
        .where(Product.tenant_id == session.tenant_id)
        .options(joinedload(Product.mix))
        .join(Product.mix)
        .order_by(Product.client_name, Product.name, Product.id)
        .limit(limit)
    )

    if client_name:
        statement = statement.where(Product.client_name == client_name)

    if q:
        term = f"%{q.strip()}%"
        statement = statement.where(
            or_(
                Product.client_name.ilike(term),
                Product.name.ilike(term),
                Product.item_id.ilike(term),
                Product.unit_of_measure.ilike(term),
                Mix.name.ilike(term),
            )
        )

    return [_serialize_row(product) for product in db.scalars(statement).all()]


@router.patch("/products/{product_id}", response_model=EditorProductRow)
def update_editor_product(
    product_id: int,
    payload: EditorProductUpdate,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    product = db.scalar(
        select(Product)
        .where(Product.id == product_id, Product.tenant_id == session.tenant_id)
        .options(joinedload(Product.mix))
    )
    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")

    if payload.mix_id is not None:
        mix = db.scalar(select(Mix).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id))
        if mix is None:
            raise HTTPException(status_code=404, detail="Mix not found")

    for field, value in payload.model_dump(exclude_unset=True).items():
        setattr(product, field, value)

    db.commit()
    db.refresh(product)
    return _serialize_row(product)


@router.get("/mixes", response_model=list[EditorMixRow])
def list_editor_mixes(
    q: str | None = Query(default=None, max_length=255),
    client_name: str | None = Query(default=None, max_length=255),
    limit: int = Query(default=500, ge=1, le=1000),
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    statement = select(Mix).where(Mix.tenant_id == session.tenant_id)

    if client_name:
        statement = statement.where(Mix.client_name == client_name)

    if q:
        term = f"%{q.strip()}%"
        statement = statement.where(or_(Mix.client_name.ilike(term), Mix.name.ilike(term)))

    statement = statement.order_by(Mix.client_name, Mix.name, Mix.id).limit(limit)

    counts = _mix_product_counts(db, session.tenant_id or "")
    mixes = db.scalars(statement).all()
    return [
        _serialize_mix_row(mix, visible_count=counts.get(mix.id, (0, 0))[1], product_count=counts.get(mix.id, (0, 0))[0])
        for mix in mixes
    ]


@router.post("/mixes", response_model=EditorMixRow, status_code=201)
def create_editor_mix(
    payload: EditorMixCreate,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    mix = Mix(
        tenant_id=session.tenant_id or "",
        client_name=payload.client_name.strip(),
        name=payload.name.strip(),
        notes=payload.notes,
        # Active by default so a freshly created mix shows under the default
        # "Active" filter rather than being hidden until it has a visible product.
        status="active",
    )
    db.add(mix)
    db.flush()
    record_change(
        db,
        session=session,
        entity_type=ENTITY_MIX,
        entity_id=mix.id,
        action="created",
        summary=f"Created mix “{mix.name}” for {mix.client_name}",
    )
    db.commit()
    db.refresh(mix)
    # A brand-new mix has no products yet, so it reads as Inactive (no visible products).
    return _serialize_mix_row(mix, visible_count=0, product_count=0)


@router.patch("/mixes/{mix_id}", response_model=EditorMixRow)
def update_editor_mix(
    mix_id: int,
    payload: EditorMixUpdate,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
    if mix is None:
        raise HTTPException(status_code=404, detail="Mix not found")

    updates = payload.model_dump(exclude_unset=True)
    # `visible` is a virtual field: for a mix with products it fans out to the
    # visibility of every product; for a product-less mix it maps to the mix's
    # own `status` column so the toggle still persists.
    visible = updates.pop("visible", None)

    product_total = (
        db.scalar(
            select(func.count())
            .select_from(Product)
            .where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
        )
        or 0
    )

    before = {field: getattr(mix, field) for field in updates}
    if visible is not None:
        if product_total > 0:
            visible_before = db.scalar(
                select(func.count())
                .select_from(Product)
                .where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible)
            )
            before["visible"] = bool(visible_before)
        else:
            before["visible"] = mix.status == "active"

    for field, value in updates.items():
        setattr(mix, field, value)

    if visible is not None:
        if product_total > 0:
            for product in db.scalars(
                select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
            ).all():
                product.visible = visible
        else:
            mix.status = "active" if visible else "inactive"

    after = dict(updates)
    if visible is not None:
        after["visible"] = visible
    deltas = diff_fields(
        before,
        after,
        {"name": "Mix name", "client_name": "Client", "notes": "Notes", "visible": "Status (active)"},
    )
    if deltas:
        record_change(
            db,
            session=session,
            entity_type=ENTITY_MIX,
            entity_id=mix.id,
            action="updated",
            summary=f"Updated {', '.join(delta['label'] for delta in deltas)}",
            changes=deltas,
        )

    db.commit()

    counts = _mix_product_counts(db, session.tenant_id or "")
    total, visible_count = counts.get(mix_id, (0, 0))
    return _serialize_mix_row(mix, visible_count=visible_count, product_count=total)


@router.get("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead)
def get_editor_mix_ingredients(
    mix_id: int,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
    if mix is None:
        raise HTTPException(status_code=404, detail="Mix not found")
    return _serialize_mix_formula(mix)


@router.post("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead, status_code=201)
def add_editor_mix_ingredient(
    mix_id: int,
    payload: EditorMixIngredientCreate,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
    if mix is None:
        raise HTTPException(status_code=404, detail="Mix not found")
    raw_material = db.scalar(
        select(RawMaterial).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)
    )
    if raw_material is None:
        raise HTTPException(status_code=404, detail="Raw material not found")

    db.add(
        MixIngredient(
            tenant_id=session.tenant_id or "",
            mix_id=mix_id,
            raw_material_id=payload.raw_material_id,
            quantity_kg=payload.quantity_kg,
            notes=payload.notes,
        )
    )
    record_change(
        db,
        session=session,
        entity_type=ENTITY_MIX,
        entity_id=mix_id,
        action="ingredient_added",
        summary=f"Added {raw_material.name} ({_format_kg(payload.quantity_kg)})",
        changes=[{"field": raw_material.name, "label": raw_material.name, "before": None, "after": _format_kg(payload.quantity_kg)}],
    )
    try:
        db.commit()
    except IntegrityError as exc:
        db.rollback()
        raise HTTPException(status_code=400, detail="Raw material is already on this mix") from exc

    mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
    return _serialize_mix_formula(mix)


@router.patch("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead)
def update_editor_mix_ingredient(
    mix_id: int,
    ingredient_id: int,
    payload: EditorMixIngredientUpdate,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    ingredient = db.scalar(
        select(MixIngredient)
        .join(Mix)
        .where(
            MixIngredient.id == ingredient_id,
            MixIngredient.mix_id == mix_id,
            Mix.tenant_id == session.tenant_id,
        )
    )
    if ingredient is None:
        raise HTTPException(status_code=404, detail="Ingredient not found")
    raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}"
    updates = payload.model_dump(exclude_unset=True)
    before = {field: getattr(ingredient, field) for field in updates}
    for field, value in updates.items():
        setattr(ingredient, field, value)
    deltas = diff_fields(before, updates, {"quantity_kg": f"{raw_material_name} quantity", "notes": f"{raw_material_name} notes"})
    if deltas:
        record_change(
            db,
            session=session,
            entity_type=ENTITY_MIX,
            entity_id=mix_id,
            action="ingredient_updated",
            summary=f"Updated {raw_material_name}",
            changes=deltas,
        )
    db.commit()

    mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
    return _serialize_mix_formula(mix)


@router.delete("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead)
def delete_editor_mix_ingredient(
    mix_id: int,
    ingredient_id: int,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    ingredient = db.scalar(
        select(MixIngredient)
        .join(Mix)
        .where(
            MixIngredient.id == ingredient_id,
            MixIngredient.mix_id == mix_id,
            Mix.tenant_id == session.tenant_id,
        )
    )
    if ingredient is None:
        raise HTTPException(status_code=404, detail="Ingredient not found")
    raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}"
    removed_kg = ingredient.quantity_kg
    db.delete(ingredient)
    record_change(
        db,
        session=session,
        entity_type=ENTITY_MIX,
        entity_id=mix_id,
        action="ingredient_removed",
        summary=f"Removed {raw_material_name}",
        changes=[{"field": raw_material_name, "label": raw_material_name, "before": _format_kg(removed_kg), "after": None}],
    )
    db.commit()

    mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
    return _serialize_mix_formula(mix)


@router.get("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
def get_editor_mix_resolved_formula(
    mix_id: int,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    """The mix formula as the Mix Calculator reads it (product-first resolution).

    This is what the Mix Editor displays, so the two surfaces show identical
    ingredients and quantities. See `resolve_editor_mix_formula`.
    """
    tenant_id = session.tenant_id or ""
    mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
    if mix is None:
        raise HTTPException(status_code=404, detail="Mix not found")
    return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)


@router.put("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
def replace_editor_mix_formula(
    mix_id: int,
    payload: EditorMixFormulaReplace,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    """Replace a mix's whole formula in one save.

    Writes back to the *same source* the Mix Calculator reads: the representative
    product's own formula (`ProductIngredient`) when it has one, otherwise the
    shared mix master (`MixIngredient`). Either way the calculator immediately
    reflects the edit.
    """
    tenant_id = session.tenant_id or ""
    mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
    if mix is None:
        raise HTTPException(status_code=404, detail="Mix not found")

    # Snapshot the formula as it stands so we can diff it against the saved one.
    before_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)

    raw_ids = [row.raw_material_id for row in payload.rows]
    if len(set(raw_ids)) != len(raw_ids):
        raise HTTPException(status_code=400, detail="Each raw material can only appear once in a mix")
    existing_ids = set(
        db.scalars(
            select(RawMaterial.id).where(RawMaterial.tenant_id == tenant_id, RawMaterial.id.in_(raw_ids))
        ).all()
    )
    missing = [raw_id for raw_id in raw_ids if raw_id not in existing_ids]
    if missing:
        raise HTTPException(status_code=404, detail="Raw material not found")

    product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix_id)
    if product is not None and product.ingredients:
        # Replace the representative product's own formula.
        for ingredient in list(product.ingredients):
            db.delete(ingredient)
        db.flush()
        for sort_order, row in enumerate(payload.rows, start=1):
            db.add(
                ProductIngredient(
                    tenant_id=tenant_id,
                    product_id=product.id,
                    raw_material_id=row.raw_material_id,
                    quantity_kg=row.quantity_kg,
                    sort_order=sort_order,
                    notes=row.notes,
                )
            )
    else:
        # No product-specific formula in play: edit the shared mix master, which
        # is what the calculator falls back to for this mix.
        for ingredient in list(mix.ingredients):
            db.delete(ingredient)
        db.flush()
        for row in payload.rows:
            db.add(
                MixIngredient(
                    tenant_id=tenant_id,
                    mix_id=mix.id,
                    raw_material_id=row.raw_material_id,
                    quantity_kg=row.quantity_kg,
                    notes=row.notes,
                )
            )

    db.flush()
    mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
    after_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
    deltas = _formula_deltas(before_formula.ingredients, after_formula.ingredients)
    if deltas:
        record_change(
            db,
            session=session,
            entity_type=ENTITY_MIX,
            entity_id=mix_id,
            action="formula_updated",
            summary=f"Updated formula ({len(deltas)} ingredient {'change' if len(deltas) == 1 else 'changes'})",
            changes=deltas,
        )
    db.commit()
    return after_formula


@router.get("/mixes/{mix_id}/history", response_model=list[EditorChangeEventRead])
def get_editor_mix_history(
    mix_id: int,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    tenant_id = session.tenant_id or ""
    if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == tenant_id)) is None:
        raise HTTPException(status_code=404, detail="Mix not found")
    events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_MIX, entity_id=mix_id)
    return [_serialize_change_event(event) for event in events]


@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
def get_editor_product_ingredients(
    product_id: int,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")
    return _serialize_product_formula(product)


@router.post("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead, status_code=201)
def add_editor_product_ingredient(
    product_id: int,
    payload: EditorProductIngredientCreate,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")
    if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None:
        raise HTTPException(status_code=404, detail="Raw material not found")

    next_sort_order = (
        db.scalar(
            select(func.coalesce(func.max(ProductIngredient.sort_order), 0)).where(ProductIngredient.product_id == product_id)
        )
        or 0
    ) + 1
    db.add(
        ProductIngredient(
            tenant_id=session.tenant_id or "",
            product_id=product_id,
            raw_material_id=payload.raw_material_id,
            quantity_kg=payload.quantity_kg,
            sort_order=next_sort_order,
            notes=payload.notes,
        )
    )
    try:
        db.commit()
    except IntegrityError as exc:
        db.rollback()
        raise HTTPException(status_code=400, detail="Raw material is already on this product") from exc

    product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
    return _serialize_product_formula(product)


@router.patch("/products/{product_id}/ingredients/{ingredient_id}", response_model=EditorProductFormulaRead)
def update_editor_product_ingredient(
    product_id: int,
    ingredient_id: int,
    payload: EditorProductIngredientUpdate,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    ingredient = db.scalar(
        select(ProductIngredient)
        .join(Product)
        .where(
            ProductIngredient.id == ingredient_id,
            ProductIngredient.product_id == product_id,
            Product.tenant_id == session.tenant_id,
        )
    )
    if ingredient is None:
        raise HTTPException(status_code=404, detail="Ingredient not found")
    for field, value in payload.model_dump(exclude_unset=True).items():
        setattr(ingredient, field, value)
    db.commit()

    product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
    return _serialize_product_formula(product)


@router.delete("/products/{product_id}/ingredients/{ingredient_id}", response_model=EditorProductFormulaRead)
def delete_editor_product_ingredient(
    product_id: int,
    ingredient_id: int,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    ingredient = db.scalar(
        select(ProductIngredient)
        .join(Product)
        .where(
            ProductIngredient.id == ingredient_id,
            ProductIngredient.product_id == product_id,
            Product.tenant_id == session.tenant_id,
        )
    )
    if ingredient is None:
        raise HTTPException(status_code=404, detail="Ingredient not found")
    db.delete(ingredient)
    db.commit()

    product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
    return _serialize_product_formula(product)


# --- Ingredients (raw materials) catalogue -----------------------------------
#
# The mix editor consumes raw materials as the ingredients dropdown; this gives
# Lean admins a sibling editor to curate that catalogue — the ingredients that
# ultimately get used inside mixes. Same auth/tenant model as the mix editor.


def _serialize_ingredient(material: RawMaterial, usage_count: int) -> dict:
    active_price = get_active_price(material)
    cost_per_kg = (
        calculate_raw_material_cost(material, active_price).cost_per_kg if active_price is not None else None
    )
    return {
        "id": material.id,
        "name": material.name,
        "supplier": material.supplier,
        "unit_of_measure": material.unit_of_measure,
        "kg_per_unit": material.kg_per_unit,
        "status": material.status,
        "rounding_decimals": material.rounding_decimals,
        "notes": material.notes,
        "cost_per_kg": cost_per_kg,
        "usage_count": usage_count,
        "created_at": material.created_at,
    }


def _ingredient_usage_counts(db: Session, tenant_id: str) -> dict[int, int]:
    """How many product/mix formula rows reference each raw material."""
    rows = db.execute(
        select(ProductIngredient.raw_material_id, func.count())
        .where(ProductIngredient.tenant_id == tenant_id)
        .group_by(ProductIngredient.raw_material_id)
    ).all()
    return {raw_material_id: count for raw_material_id, count in rows}


@router.get("/ingredients", response_model=list[EditorIngredientRow])
def list_editor_ingredients(
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    tenant_id = session.tenant_id or ""
    materials = db.scalars(
        select(RawMaterial)
        .where(RawMaterial.tenant_id == tenant_id)
        .options(selectinload(RawMaterial.price_versions))
        .order_by(RawMaterial.name)
    ).all()
    usage = _ingredient_usage_counts(db, tenant_id)
    return [_serialize_ingredient(material, usage.get(material.id, 0)) for material in materials]


@router.post("/ingredients", response_model=EditorIngredientRow, status_code=201)
def create_editor_ingredient(
    payload: EditorIngredientCreate,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    material = RawMaterial(
        tenant_id=session.tenant_id or "",
        name=payload.name.strip(),
        supplier=(payload.supplier or "").strip() or None,
        unit_of_measure=payload.unit_of_measure.strip(),
        kg_per_unit=payload.kg_per_unit,
        status=payload.status.strip() or "active",
        rounding_decimals=payload.rounding_decimals,
        notes=payload.notes,
    )
    db.add(material)
    try:
        db.flush()
        record_change(
            db,
            session=session,
            entity_type=ENTITY_INGREDIENT,
            entity_id=material.id,
            action="created",
            summary=f"Created ingredient “{material.name}”",
        )
        db.commit()
    except IntegrityError as exc:
        db.rollback()
        raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc
    db.refresh(material)
    return _serialize_ingredient(material, 0)


@router.patch("/ingredients/{ingredient_id}", response_model=EditorIngredientRow)
def update_editor_ingredient(
    ingredient_id: int,
    payload: EditorIngredientUpdate,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    tenant_id = session.tenant_id or ""
    material = db.scalar(
        select(RawMaterial)
        .where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)
        .options(selectinload(RawMaterial.price_versions))
    )
    if material is None:
        raise HTTPException(status_code=404, detail="Ingredient not found")
    updates = payload.model_dump(exclude_unset=True)
    if "name" in updates and updates["name"] is not None:
        updates["name"] = updates["name"].strip()
    if "supplier" in updates:
        updates["supplier"] = (updates["supplier"] or "").strip() or None
    if "unit_of_measure" in updates and updates["unit_of_measure"] is not None:
        updates["unit_of_measure"] = updates["unit_of_measure"].strip()
    before = {field: getattr(material, field) for field in updates}
    for field, value in updates.items():
        setattr(material, field, value)
    deltas = diff_fields(
        before,
        updates,
        {
            "name": "Name",
            "supplier": "Supplier",
            "unit_of_measure": "Unit of measure",
            "kg_per_unit": "Kg per unit",
            "status": "Status",
            "rounding_decimals": "Rounding (dp)",
            "notes": "Notes",
        },
    )
    if deltas:
        record_change(
            db,
            session=session,
            entity_type=ENTITY_INGREDIENT,
            entity_id=material.id,
            action="updated",
            summary=f"Updated {', '.join(delta['label'] for delta in deltas)}",
            changes=deltas,
        )
    try:
        db.commit()
    except IntegrityError as exc:
        db.rollback()
        raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc
    db.refresh(material)
    usage = _ingredient_usage_counts(db, tenant_id)
    return _serialize_ingredient(material, usage.get(material.id, 0))


@router.get("/ingredients/{ingredient_id}/history", response_model=list[EditorChangeEventRead])
def get_editor_ingredient_history(
    ingredient_id: int,
    session: AuthSession = Depends(_require_editor_session),
    db: Session = Depends(get_db),
):
    tenant_id = session.tenant_id or ""
    if db.scalar(select(RawMaterial.id).where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)) is None:
        raise HTTPException(status_code=404, detail="Ingredient not found")
    events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_INGREDIENT, entity_id=ingredient_id)
    return [_serialize_change_event(event) for event in events]
