"""
Recipe API — CRUD, costing, sub-recipe cycle check, scaling, menu sections,
cost snapshots, print HTML (full + kitchen card).
"""
import os
import uuid
import logging
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional
from html import escape as html_escape

from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text, and_, delete
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, field_serializer

from database import get_db
from models.user import User
from models.recipe import (
    Recipe, MenuSection, RecipeIngredient, RecipeSubRecipe,
    RecipeStep, RecipeImage, RecipeChangeLog, RecipeCostSnapshot,
)
from models.ingredient import Ingredient, IngredientSource
from models.food_flag import RecipeFlag, FoodFlagCategory, FoodFlag
from models.ingredient import IngredientFlag, IngredientFlagNone
from models.settings import KitchenSettings
from auth import get_current_user, require_cap, get_current_user_from_token
from api.ingredients import convert_to_standard, UNIT_CONVERSIONS
from models.menu import Menu, MenuItem

logger = logging.getLogger(__name__)

router = APIRouter()

DATA_DIR = "/app/data"


# ── Pydantic schemas ─────────────────────────────────────────────────────────

class MenuSectionCreate(BaseModel):
    name: str
    sort_order: int = 0
    section_type: str = "recipe"  # "recipe" | "dish"

class MenuSectionUpdate(BaseModel):
    name: Optional[str] = None
    sort_order: Optional[int] = None

class MenuSectionResponse(BaseModel):
    id: int
    name: str
    sort_order: int
    section_type: str = "recipe"
    recipe_count: int = 0

class RecipeCreate(BaseModel):
    name: str
    recipe_type: str = "component"  # "component" | "dish"
    menu_section_id: Optional[int] = None
    description: Optional[str] = None
    batch_portions: int = 1
    batch_output_type: str = "portions"  # "portions" | "bulk"
    batch_yield_qty: Optional[float] = None
    batch_yield_unit: Optional[str] = None  # g, kg, ml, ltr
    prep_time_minutes: Optional[int] = None
    cook_time_minutes: Optional[int] = None
    notes: Optional[str] = None

class RecipeUpdate(BaseModel):
    name: Optional[str] = None
    recipe_type: Optional[str] = None
    menu_section_id: Optional[int] = None
    description: Optional[str] = None
    batch_portions: Optional[int] = None
    batch_output_type: Optional[str] = None
    batch_yield_qty: Optional[float] = None
    batch_yield_unit: Optional[str] = None
    prep_time_minutes: Optional[int] = None
    cook_time_minutes: Optional[int] = None
    notes: Optional[str] = None
    is_archived: Optional[bool] = None
    kds_menu_item_name: Optional[str] = None
    sambapos_portion_name: Optional[str] = None
    gross_sell_price: Optional[float] = None

class IngredientAdd(BaseModel):
    ingredient_id: int
    quantity: float
    unit: Optional[str] = None  # override display unit (e.g. kg when ingredient std is g)
    yield_percent: float = 100.0
    notes: Optional[str] = None
    sort_order: int = 0

class IngredientUpdateSchema(BaseModel):
    quantity: Optional[float] = None
    unit: Optional[str] = None
    yield_percent: Optional[float] = None
    notes: Optional[str] = None
    sort_order: Optional[int] = None

class SubRecipeAdd(BaseModel):
    child_recipe_id: int
    portions_needed: float
    portions_needed_unit: Optional[str] = None  # override unit (e.g. ml when child yields ltr)
    notes: Optional[str] = None
    sort_order: int = 0

class SubRecipeUpdateSchema(BaseModel):
    portions_needed: Optional[float] = None
    portions_needed_unit: Optional[str] = None
    notes: Optional[str] = None
    sort_order: Optional[int] = None

class StepCreate(BaseModel):
    title: Optional[str] = None
    instruction: str
    step_number: int = 0
    duration_minutes: Optional[int] = None
    notes: Optional[str] = None

class StepUpdate(BaseModel):
    title: Optional[str] = None
    instruction: Optional[str] = None
    step_number: Optional[int] = None
    duration_minutes: Optional[int] = None
    notes: Optional[str] = None

class StepReorder(BaseModel):
    step_ids: list[int]

class IngredientReorder(BaseModel):
    ingredient_ids: list[int]

class SubRecipeReorder(BaseModel):
    sub_recipe_ids: list[int]

class RecipeListItem(BaseModel):
    id: int
    name: str
    recipe_type: str
    menu_section_id: Optional[int] = None
    menu_section_name: Optional[str] = None
    batch_portions: int = 1
    batch_output_type: str = "portions"
    batch_yield_qty: Optional[float] = None
    batch_yield_unit: Optional[str] = None
    output_unit: str = "portion"
    cost_per_portion: Optional[float] = None
    total_cost: Optional[float] = None
    is_archived: bool = False
    prep_time_minutes: Optional[int] = None
    cook_time_minutes: Optional[int] = None
    flag_summary: list[dict] = []
    image_count: int = 0
    gross_sell_price: Optional[float] = None
    kds_menu_item_name: Optional[str] = None
    sambapos_portion_name: Optional[str] = None
    created_at: str = ""
    updated_at: str = ""


# ── Menu Section endpoints ───────────────────────────────────────────────────

@router.get("/menu-sections")
async def list_menu_sections(
    section_type: Optional[str] = Query(None),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    count_sub = (
        select(Recipe.menu_section_id, func.count(Recipe.id).label("cnt"))
        .where(Recipe.kitchen_id == user.kitchen_id, Recipe.is_archived == False)
        .group_by(Recipe.menu_section_id)
        .subquery()
    )
    query = (
        select(MenuSection, func.coalesce(count_sub.c.cnt, 0).label("recipe_count"))
        .outerjoin(count_sub, MenuSection.id == count_sub.c.menu_section_id)
        .where(MenuSection.kitchen_id == user.kitchen_id)
    )
    if section_type:
        query = query.where(MenuSection.section_type == section_type)
    query = query.order_by(MenuSection.sort_order, MenuSection.name)
    result = await db.execute(query)
    return [
        MenuSectionResponse(id=s.id, name=s.name, sort_order=s.sort_order, section_type=s.section_type, recipe_count=cnt)
        for s, cnt in result.all()
    ]


@router.post("/menu-sections")
async def create_menu_section(
    data: MenuSectionCreate,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    sec = MenuSection(kitchen_id=user.kitchen_id, name=data.name, sort_order=data.sort_order, section_type=data.section_type)
    db.add(sec)
    await db.commit()
    await db.refresh(sec)
    return MenuSectionResponse(id=sec.id, name=sec.name, sort_order=sec.sort_order)


@router.patch("/menu-sections/{section_id}")
async def update_menu_section(
    section_id: int,
    data: MenuSectionUpdate,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    result = await db.execute(
        select(MenuSection).where(MenuSection.id == section_id, MenuSection.kitchen_id == user.kitchen_id)
    )
    sec = result.scalar_one_or_none()
    if not sec:
        raise HTTPException(404, "Section not found")
    if data.name is not None:
        sec.name = data.name
    if data.sort_order is not None:
        sec.sort_order = data.sort_order
    await db.commit()
    return {"ok": True}


@router.delete("/menu-sections/{section_id}")
async def delete_menu_section(
    section_id: int,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    result = await db.execute(
        select(MenuSection).where(MenuSection.id == section_id, MenuSection.kitchen_id == user.kitchen_id)
    )
    sec = result.scalar_one_or_none()
    if not sec:
        raise HTTPException(404, "Section not found")
    # Null out recipes in this section
    from sqlalchemy import update
    await db.execute(
        update(Recipe).where(Recipe.menu_section_id == section_id).values(menu_section_id=None)
    )
    await db.delete(sec)
    await db.commit()
    return {"ok": True}


# ── Default recipe sections and dish courses ────────────────────────────────

DEFAULT_RECIPE_SECTIONS = [
    ("Meats & Protein", 0),
    ("Sauces & Jus", 1),
    ("Starch & Vegetables", 2),
    ("Sides & Accompaniments", 3),
    ("Pastry & Dessert", 4),
    ("Garnish & Toppings", 5),
    ("Marinades & Glazes", 6),
    ("Stews & Casseroles", 7),
]

DEFAULT_DISH_COURSES = [
    ("Starter", 0),
    ("Main", 1),
    ("Dessert", 2),
    ("Side", 3),
    ("Specials", 4),
]


@router.post("/menu-sections/seed-defaults")
async def seed_default_menu_sections(
    section_type: str = Query("recipe"),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    if section_type not in ("recipe", "dish"):
        raise HTTPException(400, "section_type must be 'recipe' or 'dish'")

    defaults = DEFAULT_RECIPE_SECTIONS if section_type == "recipe" else DEFAULT_DISH_COURSES
    kid = user.kitchen_id
    created = 0
    for name, sort_order in defaults:
        exists = await db.execute(
            select(MenuSection).where(
                MenuSection.kitchen_id == kid,
                MenuSection.name == name,
                MenuSection.section_type == section_type,
            )
        )
        if exists.scalar_one_or_none():
            continue
        db.add(MenuSection(kitchen_id=kid, name=name, section_type=section_type, sort_order=sort_order))
        created += 1
    await db.commit()
    return {"ok": True, "created": created}


# ── Dashboard Stats ──────────────────────────────────────────────────────────

@router.get("/dashboard-stats")
async def recipe_dashboard_stats(
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Lightweight stats for the main dashboard widget."""
    kid = user.kitchen_id

    total = (await db.execute(
        select(func.count()).select_from(Recipe).where(Recipe.kitchen_id == kid, Recipe.is_archived == False)
    )).scalar() or 0

    dishes = (await db.execute(
        select(func.count()).select_from(Recipe).where(Recipe.kitchen_id == kid, Recipe.recipe_type == "dish", Recipe.is_archived == False)
    )).scalar() or 0

    component = total - dishes

    # Ingredients with no sources
    unmapped = (await db.execute(
        select(func.count()).select_from(Ingredient).where(
            Ingredient.kitchen_id == kid,
            Ingredient.is_archived == False,
            ~Ingredient.id.in_(
                select(IngredientSource.ingredient_id).where(IngredientSource.kitchen_id == kid)
            ),
        )
    )).scalar() or 0

    # Recipes without any cost snapshots
    without_costing = (await db.execute(
        select(func.count()).select_from(Recipe).where(
            Recipe.kitchen_id == kid,
            Recipe.is_archived == False,
            ~Recipe.id.in_(
                select(RecipeCostSnapshot.recipe_id).distinct()
            ),
        )
    )).scalar() or 0

    # Dishes missing allergen assessment
    # Find required flag categories for this kitchen
    req_cats = (await db.execute(
        select(FoodFlagCategory.id).where(
            FoodFlagCategory.kitchen_id == kid,
            FoodFlagCategory.required == True,
        )
    )).scalars().all()

    dishes_missing_allergens = 0
    dishes_missing_list: list[dict] = []

    if req_cats:
        # Get all dish recipes (non-archived)
        dish_rows = (await db.execute(
            select(Recipe.id, Recipe.name).where(
                Recipe.kitchen_id == kid,
                Recipe.recipe_type == "dish",
                Recipe.is_archived == False,
            )
        )).all()

        for dish_id, dish_name in dish_rows:
            # Get all ingredient IDs used by this dish (direct + via sub-recipes)
            direct_ings = (await db.execute(
                select(RecipeIngredient.ingredient_id).where(
                    RecipeIngredient.recipe_id == dish_id
                )
            )).scalars().all()

            sub_recipe_ids = (await db.execute(
                select(RecipeSubRecipe.child_recipe_id).where(
                    RecipeSubRecipe.parent_recipe_id == dish_id
                )
            )).scalars().all()

            sub_ings: list[int] = []
            for sr_id in sub_recipe_ids:
                sr_ings = (await db.execute(
                    select(RecipeIngredient.ingredient_id).where(
                        RecipeIngredient.recipe_id == sr_id
                    )
                )).scalars().all()
                sub_ings.extend(sr_ings)

            all_ing_ids = set(direct_ings) | set(sub_ings)
            if not all_ing_ids:
                continue

            # Check each ingredient against required categories
            has_unassessed = False
            for ing_id in all_ing_ids:
                for cat_id in req_cats:
                    # Check if ingredient has any flag in this category
                    has_flag = (await db.execute(
                        select(IngredientFlag.id).where(
                            IngredientFlag.ingredient_id == ing_id,
                            IngredientFlag.food_flag_id.in_(
                                select(FoodFlag.id).where(FoodFlag.category_id == cat_id)
                            ),
                        ).limit(1)
                    )).scalar_one_or_none()

                    if not has_flag:
                        # Check if ingredient has "none" for this category
                        has_none = (await db.execute(
                            select(IngredientFlagNone.id).where(
                                IngredientFlagNone.ingredient_id == ing_id,
                                IngredientFlagNone.category_id == cat_id,
                            ).limit(1)
                        )).scalar_one_or_none()

                        if not has_none:
                            has_unassessed = True
                            break
                if has_unassessed:
                    break

            if has_unassessed:
                dishes_missing_allergens += 1
                dishes_missing_list.append({"id": dish_id, "name": dish_name})

    # Recipes affected by ingredient price changes in last 14 days
    cutoff = datetime.utcnow() - timedelta(days=14)
    price_change_logs = (await db.execute(
        select(RecipeChangeLog.recipe_id).where(
            RecipeChangeLog.created_at >= cutoff,
            RecipeChangeLog.user_id == None,
            (RecipeChangeLog.change_summary.like("%price changed%") | RecipeChangeLog.change_summary.like("%price set%")),
            RecipeChangeLog.recipe_id.in_(
                select(Recipe.id).where(Recipe.kitchen_id == kid, Recipe.is_archived == False)
            ),
        ).distinct()
    )).scalars().all()

    # Menu items needing republishing
    from api.menus import _compute_staleness
    menu_result = await db.execute(
        select(Menu).options(selectinload(Menu.items)).where(
            Menu.kitchen_id == kid, Menu.is_active == True
        )
    )
    active_menus = menu_result.scalars().all()
    stale_menu_items = 0
    stale_menu_names: list[str] = []
    for menu in active_menus:
        if menu.items:
            staleness = await _compute_staleness(list(menu.items), db)
            menu_stale = sum(1 for s in staleness.values() if s.get("is_stale"))
            if menu_stale > 0:
                stale_menu_items += menu_stale
                stale_menu_names.append(menu.name)

    return {
        "total_recipes": total,
        "dish_count": dishes,
        "component_recipes": component,
        "unmapped_ingredients": unmapped,
        "recipes_without_costing": without_costing,
        "dishes_missing_allergens": dishes_missing_allergens,
        "dishes_missing_allergens_list": dishes_missing_list,
        "recipes_with_price_changes": len(price_change_logs),
        "stale_menu_items": stale_menu_items,
        "stale_menu_names": stale_menu_names,
    }


@router.get("/price-impact")
async def price_impact_report(
    days: int = Query(14, ge=1, le=365),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Recipes affected by ingredient price changes within the given period."""
    kid = user.kitchen_id
    cutoff = datetime.utcnow() - timedelta(days=days)

    # Find price-change log entries within the period
    log_result = await db.execute(
        select(RecipeChangeLog).where(
            RecipeChangeLog.created_at >= cutoff,
            RecipeChangeLog.user_id == None,
            (RecipeChangeLog.change_summary.like("%price changed%") | RecipeChangeLog.change_summary.like("%price set%")),
            RecipeChangeLog.recipe_id.in_(
                select(Recipe.id).where(Recipe.kitchen_id == kid, Recipe.is_archived == False)
            ),
        ).order_by(RecipeChangeLog.created_at.desc())
    )
    logs = log_result.scalars().all()

    if not logs:
        return {"days": days, "recipes": []}

    import re
    price_change_re = re.compile(
        r"^(.+?) price changed: £([\d.]+)(/(\w+))? → £([\d.]+)(/(\w+))?$"
    )
    price_set_re = re.compile(
        r"^(.+?) price set: £([\d.]+)(/(\w+))?$"
    )

    # Batch-load invoice numbers for logs that have source_invoice_id
    invoice_ids = {log.source_invoice_id for log in logs if log.source_invoice_id}
    invoice_map: dict[int, str] = {}
    if invoice_ids:
        from models.invoice import Invoice
        inv_result = await db.execute(
            select(Invoice.id, Invoice.invoice_number).where(Invoice.id.in_(invoice_ids))
        )
        invoice_map = {row[0]: row[1] for row in inv_result.fetchall()}

    # Group log entries by recipe, parsing structured data from summary
    recipe_changes: dict[int, list[dict]] = {}
    for log in logs:
        entry: dict = {
            "summary": log.change_summary,
            "date": str(log.created_at.date()) if log.created_at else None,
            "ingredient_name": None,
            "old_price": None,
            "new_price": None,
            "unit": None,
            "source_invoice_id": log.source_invoice_id,
            "source_invoice_number": invoice_map.get(log.source_invoice_id) if log.source_invoice_id else None,
        }
        m = price_change_re.match(log.change_summary)
        if m:
            entry["ingredient_name"] = m.group(1)
            entry["old_price"] = float(m.group(2))
            entry["new_price"] = float(m.group(5))
            entry["unit"] = m.group(4) or m.group(7)
        else:
            m2 = price_set_re.match(log.change_summary)
            if m2:
                entry["ingredient_name"] = m2.group(1)
                entry["new_price"] = float(m2.group(2))
                entry["unit"] = m2.group(4)
        recipe_changes.setdefault(log.recipe_id, []).append(entry)

    # Get recipe info + cost snapshots for affected recipes
    recipe_ids = list(recipe_changes.keys())
    recipe_result = await db.execute(
        select(Recipe)
        .options(
            selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient),
            selectinload(Recipe.sub_recipes)
                .selectinload(RecipeSubRecipe.child_recipe)
                .options(
                    selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient),
                    selectinload(Recipe.sub_recipes)
                        .selectinload(RecipeSubRecipe.child_recipe)
                        .selectinload(Recipe.ingredients)
                        .selectinload(RecipeIngredient.ingredient),
                ),
        )
        .where(Recipe.id.in_(recipe_ids))
    )
    recipes = {r.id: r for r in recipe_result.scalars().all()}

    def _build_ing_usage(recipe, scale: float = 1.0, depth: int = 0) -> dict[str, float]:
        """Recursively build ingredient name -> qty_in_std_unit map, expanding sub-recipes."""
        usage: dict[str, float] = {}
        for ri in recipe.ingredients:
            if ri.ingredient and ri.quantity:
                display_unit = ri.unit or ri.ingredient.standard_unit
                qty_std = _convert_unit(float(ri.quantity), display_unit, ri.ingredient.standard_unit)
                yld = float(ri.yield_percent) if ri.yield_percent else 100.0
                qty_effective = (qty_std / (yld / 100) if yld > 0 else qty_std) * scale
                key = ri.ingredient.name.lower()
                usage[key] = usage.get(key, 0) + qty_effective
        if depth < 3:
            try:
                for sr in recipe.sub_recipes:
                    child = sr.child_recipe
                    if not child:
                        continue
                    child_output = _get_output_qty(child)
                    if not child_output:
                        continue
                    needed = float(sr.portions_needed) if sr.portions_needed else 0
                    if not needed:
                        continue
                    # Convert portions_needed unit to child output unit if needed (bulk sub-recipes)
                    child_output_unit = (_get_output_unit(child)).lower().strip()
                    needed_unit = (sr.portions_needed_unit or child_output_unit).lower().strip()
                    if needed_unit != child_output_unit and child.batch_output_type == "bulk":
                        converted = _convert_unit(needed, needed_unit, child_output_unit)
                        if converted is not None:
                            needed = converted
                    sub_scale = scale * needed / child_output
                    child_usage = _build_ing_usage(child, sub_scale, depth + 1)
                    for k, v in child_usage.items():
                        usage[k] = usage.get(k, 0) + v
            except Exception:
                pass  # lazy load guard
        return usage

    items = []
    for rid in recipe_ids:
        r = recipes.get(rid)
        if not r:
            continue

        output_qty = _get_output_qty(r)

        # Build ingredient name -> usage map for cost impact calculation (including sub-recipes)
        ing_usage = _build_ing_usage(r)

        # Calculate per-change cost impact
        for change in recipe_changes[rid]:
            impact = None
            if change["old_price"] is not None and change["new_price"] is not None and change["ingredient_name"]:
                qty = ing_usage.get(change["ingredient_name"].lower())
                if qty is not None:
                    price_diff = change["new_price"] - change["old_price"]
                    # Impact per output unit (portion/kg/etc)
                    impact = round(price_diff * qty / output_qty, 4) if output_qty else None
            change["cost_impact"] = impact

        # Latest snapshot (current cost)
        latest_snap = (await db.execute(
            select(RecipeCostSnapshot).where(
                RecipeCostSnapshot.recipe_id == rid,
            ).order_by(RecipeCostSnapshot.snapshot_date.desc()).limit(1)
        )).scalar_one_or_none()

        # Snapshot just before the period (previous cost)
        prev_snap = (await db.execute(
            select(RecipeCostSnapshot).where(
                RecipeCostSnapshot.recipe_id == rid,
                RecipeCostSnapshot.snapshot_date < cutoff.date(),
            ).order_by(RecipeCostSnapshot.snapshot_date.desc()).limit(1)
        )).scalar_one_or_none()

        current_cost = float(latest_snap.cost_per_portion) if latest_snap else None
        previous_cost = float(prev_snap.cost_per_portion) if prev_snap else None
        cost_change = None
        cost_change_pct = None
        if current_cost is not None and previous_cost is not None and previous_cost > 0:
            cost_change = round(current_cost - previous_cost, 4)
            cost_change_pct = round((cost_change / previous_cost) * 100, 1)

        items.append({
            "recipe_id": rid,
            "recipe_name": r.name,
            "recipe_type": r.recipe_type,
            "output_unit": _get_output_unit(r),
            "current_cost_per_unit": current_cost,
            "previous_cost_per_unit": previous_cost,
            "cost_change": cost_change,
            "cost_change_pct": cost_change_pct,
            "ingredient_changes": recipe_changes[rid],
        })

    # Sort by absolute cost change descending (biggest movers first)
    items.sort(key=lambda x: abs(x["cost_change"] or 0), reverse=True)

    return {"days": days, "recipes": items}


# ── Recipe List & CRUD ───────────────────────────────────────────────────────

@router.get("")
async def list_recipes(
    recipe_type: Optional[str] = Query(None),
    menu_section_id: Optional[int] = Query(None),
    search: Optional[str] = Query(None),
    archived: bool = Query(False),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    query = (
        select(Recipe)
        .options(selectinload(Recipe.menu_section), selectinload(Recipe.images))
        .where(Recipe.kitchen_id == user.kitchen_id)
    )
    if not archived:
        query = query.where(Recipe.is_archived == False)
    if recipe_type:
        query = query.where(Recipe.recipe_type == recipe_type)
    if menu_section_id:
        query = query.where(Recipe.menu_section_id == menu_section_id)
    if search:
        query = query.where(Recipe.name.ilike(f"%{search}%"))

    result = await db.execute(query.order_by(Recipe.name))
    recipes = result.scalars().all()

    items = []
    for r in recipes:
        # Get latest cost snapshot
        snap_result = await db.execute(
            select(RecipeCostSnapshot)
            .where(RecipeCostSnapshot.recipe_id == r.id)
            .order_by(RecipeCostSnapshot.snapshot_date.desc())
            .limit(1)
        )
        snap = snap_result.scalar_one_or_none()

        # Get flag summary (lightweight)
        from api.food_flags import compute_recipe_flags
        flags = await compute_recipe_flags(r.id, user.kitchen_id, db)
        flag_summary = [
            {"name": f.flag_name, "code": f.flag_code, "icon": f.flag_icon,
             "category": f.category_name, "propagation": f.propagation_type,
             "active": f.is_active, "excludable": f.excludable_on_request}
            for f in flags if f.is_active
        ]

        items.append(RecipeListItem(
            id=r.id,
            name=r.name,
            recipe_type=r.recipe_type,
            menu_section_id=r.menu_section_id,
            menu_section_name=r.menu_section.name if r.menu_section else None,
            batch_portions=r.batch_portions,
            batch_output_type=r.batch_output_type or "portions",
            batch_yield_qty=float(r.batch_yield_qty) if r.batch_yield_qty else None,
            batch_yield_unit=r.batch_yield_unit,
            output_unit=_get_output_unit(r),
            cost_per_portion=float(snap.cost_per_portion) if snap else None,
            total_cost=float(snap.total_cost) if snap else None,
            is_archived=r.is_archived,
            prep_time_minutes=r.prep_time_minutes,
            cook_time_minutes=r.cook_time_minutes,
            flag_summary=flag_summary,
            image_count=len(r.images) if r.images else 0,
            gross_sell_price=float(r.gross_sell_price) if r.gross_sell_price else None,
            kds_menu_item_name=r.kds_menu_item_name,
            sambapos_portion_name=r.sambapos_portion_name,
            created_at=str(r.created_at) if r.created_at else "",
            updated_at=str(r.updated_at) if r.updated_at else "",
        ))

    return items


@router.post("")
async def create_recipe(
    data: RecipeCreate,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    if data.recipe_type not in ("component", "dish"):
        raise HTTPException(400, "recipe_type must be 'component' or 'dish'")

    # Validate bulk output
    if data.batch_output_type == "bulk":
        if not data.batch_yield_qty or data.batch_yield_qty <= 0:
            raise HTTPException(400, "Bulk recipes require a positive batch_yield_qty")
        if data.batch_yield_unit not in ("g", "kg", "ml", "ltr"):
            raise HTTPException(400, "batch_yield_unit must be g, kg, ml, or ltr")

    recipe = Recipe(
        kitchen_id=user.kitchen_id,
        name=data.name.strip(),
        recipe_type=data.recipe_type,
        menu_section_id=data.menu_section_id,
        description=data.description,
        batch_portions=1 if data.batch_output_type == "bulk" else (data.batch_portions if data.recipe_type == "component" else 1),
        batch_output_type=data.batch_output_type if data.recipe_type == "component" else "portions",
        batch_yield_qty=Decimal(str(data.batch_yield_qty)) if data.batch_output_type == "bulk" and data.batch_yield_qty else None,
        batch_yield_unit=data.batch_yield_unit if data.batch_output_type == "bulk" else None,
        prep_time_minutes=data.prep_time_minutes,
        cook_time_minutes=data.cook_time_minutes,
        notes=data.notes,
        created_by=user.id,
    )
    db.add(recipe)
    await db.commit()
    await db.refresh(recipe)
    return {"id": recipe.id, "name": recipe.name}


@router.get("/{recipe_id}")
async def get_recipe(
    recipe_id: int,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    result = await db.execute(
        select(Recipe)
        .options(
            selectinload(Recipe.menu_section),
            selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient).selectinload(Ingredient.sources),
            selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe),
            selectinload(Recipe.steps),
            selectinload(Recipe.images),
        )
        .where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
    )
    recipe = result.scalar_one_or_none()
    if not recipe:
        raise HTTPException(404, "Recipe not found")

    # Build detailed response
    ingredients = []
    for ri in sorted(recipe.ingredients, key=lambda x: x.sort_order):
        ing = ri.ingredient
        if not ing:
            continue
        display_unit = ri.unit or ing.standard_unit
        # Get effective price (yield from recipe ingredient, not raw ingredient)
        yld = float(ri.yield_percent) if ri.yield_percent else 100.0
        eff_price = None
        if ing.sources:
            priced = [s for s in ing.sources if s.price_per_std_unit]
            if priced:
                latest = max(priced, key=lambda s: s.latest_invoice_date or date.min)
                raw = float(latest.price_per_std_unit)
                eff_price = raw / (yld / 100) if yld > 0 else raw
        is_manual_price = False
        if eff_price is None and ing.manual_price:
            eff_price = float(ing.manual_price) / (yld / 100) if yld > 0 else float(ing.manual_price)
            is_manual_price = True

        # Convert quantity to standard unit for cost calculation
        qty_in_std = _convert_unit(float(ri.quantity), display_unit, ing.standard_unit)
        cost = qty_in_std * eff_price if eff_price and ri.quantity else None

        ingredients.append({
            "id": ri.id,
            "ingredient_id": ing.id,
            "ingredient_name": ing.name,
            "quantity": float(ri.quantity),
            "unit": display_unit,
            "standard_unit": ing.standard_unit,
            "compatible_units": _get_compatible_units(ing.standard_unit),
            "yield_percent": yld,
            "effective_price": round(eff_price, 6) if eff_price else None,
            "cost": round(cost, 4) if cost else None,
            "is_manual_price": is_manual_price if not (hasattr(ing, 'is_free') and ing.is_free) else False,
            "has_no_price": (eff_price is None) if not (hasattr(ing, 'is_free') and ing.is_free) else False,
            "notes": ri.notes,
            "sort_order": ri.sort_order,
        })

    sub_recipes = []
    for sr in sorted(recipe.sub_recipes, key=lambda x: x.sort_order):
        child = sr.child_recipe
        if not child:
            continue
        # Calculate child cost from scratch (not from snapshots, which may be stale/missing)
        child_cost_data = await _calc_recipe_cost(child.id, db)
        child_total = child_cost_data.get("total_cost_recent", 0) or 0
        child_output_qty = _get_output_qty(child)
        child_cost_per_portion = child_total / child_output_qty if child_total and child_output_qty else None
        child_output_unit = _get_output_unit(child)
        needed_unit = sr.portions_needed_unit or child_output_unit
        # Convert to child output unit for costing
        needed_in_output_unit = _convert_unit(float(sr.portions_needed), needed_unit, child_output_unit)
        cost_contribution = None
        if child_cost_per_portion and sr.portions_needed:
            cost_contribution = needed_in_output_unit * child_cost_per_portion

        # Check if child recipe has any manual-priced or no-price ingredients
        child_has_manual = any(ci.get("is_manual_price") for ci in child_cost_data.get("ingredients", []))
        child_has_no_price = any(ci.get("has_no_price") for ci in child_cost_data.get("ingredients", []))
        # Also check nested sub-recipe child ingredients recursively
        def _check_subs(subs):
            m, n = False, False
            for s in (subs or []):
                for ci in s.get("child_ingredients", []):
                    if ci.get("is_manual_price"): m = True
                    if ci.get("has_no_price"): n = True
                sm, sn = _check_subs(s.get("child_sub_recipes", []))
                m = m or sm
                n = n or sn
            return m, n
        sub_m, sub_n = _check_subs(child_cost_data.get("sub_recipes", []))
        child_has_manual = child_has_manual or sub_m
        child_has_no_price = child_has_no_price or sub_n

        sub_recipes.append({
            "id": sr.id,
            "child_recipe_id": child.id,
            "child_recipe_name": child.name,
            "child_recipe_type": child.recipe_type,
            "batch_portions": child.batch_portions,
            "batch_output_type": child.batch_output_type or "portions",
            "batch_yield_qty": float(child.batch_yield_qty) if child.batch_yield_qty else None,
            "batch_yield_unit": child.batch_yield_unit,
            "output_qty": _get_output_qty(child),
            "output_unit": child_output_unit,
            "portions_needed": float(sr.portions_needed),
            "portions_needed_unit": needed_unit,
            "compatible_units": _get_compatible_units(child_output_unit),
            "cost_per_portion": child_cost_per_portion,
            "cost_contribution": round(cost_contribution, 4) if cost_contribution else None,
            "has_manual_price_ingredients": child_has_manual,
            "has_no_price_ingredients": child_has_no_price,
            "notes": sr.notes,
            "sort_order": sr.sort_order,
        })

    steps = [
        {
            "id": s.id, "step_number": s.step_number, "title": s.title,
            "instruction": s.instruction, "image_path": s.image_path,
            "duration_minutes": s.duration_minutes, "notes": s.notes,
        }
        for s in sorted(recipe.steps, key=lambda x: x.step_number)
    ]

    images = [
        {
            "id": img.id, "image_path": img.image_path, "caption": img.caption,
            "image_type": img.image_type, "sort_order": img.sort_order,
        }
        for img in sorted(recipe.images, key=lambda x: x.sort_order)
    ]

    return {
        "id": recipe.id,
        "name": recipe.name,
        "recipe_type": recipe.recipe_type,
        "menu_section_id": recipe.menu_section_id,
        "menu_section_name": recipe.menu_section.name if recipe.menu_section else None,
        "description": recipe.description,
        "batch_portions": recipe.batch_portions,
        "batch_output_type": recipe.batch_output_type or "portions",
        "batch_yield_qty": float(recipe.batch_yield_qty) if recipe.batch_yield_qty else None,
        "batch_yield_unit": recipe.batch_yield_unit,
        "output_qty": _get_output_qty(recipe),
        "output_unit": _get_output_unit(recipe),
        "prep_time_minutes": recipe.prep_time_minutes,
        "cook_time_minutes": recipe.cook_time_minutes,
        "notes": recipe.notes,
        "is_archived": recipe.is_archived,
        "kds_menu_item_name": recipe.kds_menu_item_name,
        "sambapos_portion_name": recipe.sambapos_portion_name,
        "gross_sell_price": float(recipe.gross_sell_price) if recipe.gross_sell_price else None,
        "ingredients": ingredients,
        "sub_recipes": sub_recipes,
        "steps": steps,
        "images": images,
        "created_at": str(recipe.created_at) if recipe.created_at else "",
        "updated_at": str(recipe.updated_at) if recipe.updated_at else "",
    }


@router.patch("/{recipe_id}")
async def update_recipe(
    recipe_id: int,
    data: RecipeUpdate,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    result = await db.execute(
        select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
    )
    recipe = result.scalar_one_or_none()
    if not recipe:
        raise HTTPException(404, "Recipe not found")

    changes = []
    if data.name is not None and data.name != recipe.name:
        changes.append(f"Name changed from '{recipe.name}' to '{data.name}'")
        recipe.name = data.name.strip()
    if data.recipe_type is not None and data.recipe_type != recipe.recipe_type:
        changes.append(f"Type changed from '{recipe.recipe_type}' to '{data.recipe_type}'")
        recipe.recipe_type = data.recipe_type
    if data.menu_section_id is not None:
        recipe.menu_section_id = data.menu_section_id
    if data.description is not None:
        recipe.description = data.description
    if data.batch_portions is not None and data.batch_portions != recipe.batch_portions:
        changes.append(f"Batch portions changed from {recipe.batch_portions} to {data.batch_portions}")
        recipe.batch_portions = data.batch_portions
    if data.batch_output_type is not None and data.batch_output_type != recipe.batch_output_type:
        changes.append(f"Output type changed from '{recipe.batch_output_type}' to '{data.batch_output_type}'")
        recipe.batch_output_type = data.batch_output_type
        if data.batch_output_type == "bulk":
            recipe.batch_portions = 1
        elif data.batch_output_type == "portions":
            recipe.batch_yield_qty = None
            recipe.batch_yield_unit = None
    if data.batch_yield_qty is not None:
        old_val = float(recipe.batch_yield_qty) if recipe.batch_yield_qty else None
        if old_val != data.batch_yield_qty:
            changes.append(f"Yield qty changed from {old_val} to {data.batch_yield_qty}")
            recipe.batch_yield_qty = Decimal(str(data.batch_yield_qty)) if data.batch_yield_qty > 0 else None
    if data.batch_yield_unit is not None and data.batch_yield_unit != recipe.batch_yield_unit:
        changes.append(f"Yield unit changed from '{recipe.batch_yield_unit}' to '{data.batch_yield_unit}'")
        recipe.batch_yield_unit = data.batch_yield_unit
    if data.prep_time_minutes is not None:
        recipe.prep_time_minutes = data.prep_time_minutes
    if data.cook_time_minutes is not None:
        recipe.cook_time_minutes = data.cook_time_minutes
    if data.notes is not None:
        recipe.notes = data.notes
    if data.is_archived is not None:
        recipe.is_archived = data.is_archived
    if data.kds_menu_item_name is not None:
        recipe.kds_menu_item_name = data.kds_menu_item_name
    if data.sambapos_portion_name is not None:
        recipe.sambapos_portion_name = data.sambapos_portion_name if data.sambapos_portion_name else None
    if data.gross_sell_price is not None:
        recipe.gross_sell_price = data.gross_sell_price if data.gross_sell_price > 0 else None

    if changes:
        db.add(RecipeChangeLog(
            recipe_id=recipe_id,
            change_summary="; ".join(changes),
            user_id=user.id,
        ))

    await db.commit()
    if changes:
        await _snapshot_recipe_and_parents(recipe_id, db, f"recipe_updated: {'; '.join(changes)}")
    return {"ok": True}


@router.delete("/{recipe_id}")
async def archive_recipe(
    recipe_id: int,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    result = await db.execute(
        select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
    )
    recipe = result.scalar_one_or_none()
    if not recipe:
        raise HTTPException(404, "Recipe not found")
    recipe.is_archived = True
    await db.commit()
    return {"ok": True}


@router.post("/{recipe_id}/duplicate")
async def duplicate_recipe(
    recipe_id: int,
    new_name: Optional[str] = Query(None),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Clone recipe with deep copy of ingredients, steps, images, flags. Sub-recipes are linked."""
    result = await db.execute(
        select(Recipe)
        .options(
            selectinload(Recipe.ingredients),
            selectinload(Recipe.sub_recipes),
            selectinload(Recipe.steps),
            selectinload(Recipe.images),
        )
        .where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
    )
    original = result.scalar_one_or_none()
    if not original:
        raise HTTPException(404, "Recipe not found")

    name = new_name or f"{original.name} (Copy)"
    clone = Recipe(
        kitchen_id=user.kitchen_id,
        name=name,
        recipe_type=original.recipe_type,
        menu_section_id=original.menu_section_id,
        description=original.description,
        batch_portions=original.batch_portions,
        batch_output_type=original.batch_output_type,
        batch_yield_qty=original.batch_yield_qty,
        batch_yield_unit=original.batch_yield_unit,
        prep_time_minutes=original.prep_time_minutes,
        cook_time_minutes=original.cook_time_minutes,
        notes=original.notes,
        created_by=user.id,
    )
    db.add(clone)
    await db.flush()

    # Copy ingredients
    for ri in original.ingredients:
        db.add(RecipeIngredient(
            recipe_id=clone.id,
            ingredient_id=ri.ingredient_id,
            quantity=ri.quantity,
            unit=ri.unit,
            notes=ri.notes,
            sort_order=ri.sort_order,
        ))

    # Link sub-recipes (not deep copy)
    for sr in original.sub_recipes:
        db.add(RecipeSubRecipe(
            parent_recipe_id=clone.id,
            child_recipe_id=sr.child_recipe_id,
            portions_needed=sr.portions_needed,
            portions_needed_unit=sr.portions_needed_unit,
            notes=sr.notes,
            sort_order=sr.sort_order,
        ))

    # Copy steps
    for step in original.steps:
        db.add(RecipeStep(
            recipe_id=clone.id,
            step_number=step.step_number,
            title=step.title,
            instruction=step.instruction,
            duration_minutes=step.duration_minutes,
            notes=step.notes,
        ))

    # Copy images (copy file references, images are shared)
    for img in original.images:
        db.add(RecipeImage(
            recipe_id=clone.id,
            image_path=img.image_path,
            caption=img.caption,
            image_type=img.image_type,
            sort_order=img.sort_order,
            uploaded_by=user.id,
        ))

    db.add(RecipeChangeLog(
        recipe_id=clone.id,
        change_summary=f"Duplicated from '{original.name}' (ID: {original.id})",
        user_id=user.id,
    ))

    await db.commit()
    return {"id": clone.id, "name": clone.name}


# ── Recipe Ingredients ───────────────────────────────────────────────────────

@router.post("/{recipe_id}/ingredients")
async def add_recipe_ingredient(
    recipe_id: int,
    data: IngredientAdd,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    recipe = await _get_recipe(recipe_id, user.kitchen_id, db)
    ing = await db.execute(
        select(Ingredient).where(Ingredient.id == data.ingredient_id, Ingredient.kitchen_id == user.kitchen_id)
    )
    ingredient = ing.scalar_one_or_none()
    if not ingredient:
        raise HTTPException(404, "Ingredient not found")

    # Store unit override if different from standard_unit
    unit_override = None
    display_unit = ingredient.standard_unit
    if data.unit and data.unit != ingredient.standard_unit:
        # Validate it's a compatible unit
        if data.unit not in _get_compatible_units(ingredient.standard_unit):
            raise HTTPException(400, f"Unit '{data.unit}' is not compatible with ingredient's standard unit '{ingredient.standard_unit}'")
        unit_override = data.unit
        display_unit = data.unit

    ri = RecipeIngredient(
        recipe_id=recipe_id,
        ingredient_id=data.ingredient_id,
        quantity=Decimal(str(data.quantity)),
        unit=unit_override,
        yield_percent=Decimal(str(data.yield_percent)),
        notes=data.notes,
        sort_order=data.sort_order,
    )
    db.add(ri)

    db.add(RecipeChangeLog(
        recipe_id=recipe_id,
        change_summary=f"Added {ingredient.name} ({data.quantity}{display_unit})",
        user_id=user.id,
    ))

    await db.commit()
    await db.refresh(ri)
    await _snapshot_recipe_and_parents(recipe_id, db, f"ingredient_added: {ingredient.name}")
    return {"id": ri.id}


@router.patch("/recipe-ingredients/{ri_id}")
async def update_recipe_ingredient(
    ri_id: int,
    data: IngredientUpdateSchema,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    ri_result = await db.execute(
        select(RecipeIngredient)
        .options(selectinload(RecipeIngredient.ingredient))
        .where(RecipeIngredient.id == ri_id)
    )
    ri = ri_result.scalar_one_or_none()
    if not ri:
        raise HTTPException(404, "Recipe ingredient not found")

    changes = []
    if data.unit is not None:
        new_unit = data.unit if data.unit != ri.ingredient.standard_unit else None
        if new_unit != ri.unit:
            old_display = ri.unit or ri.ingredient.standard_unit
            new_display = data.unit or ri.ingredient.standard_unit
            if old_display != new_display:
                changes.append(f"{ri.ingredient.name} unit changed from {old_display} to {new_display}")
            ri.unit = new_unit
    if data.quantity is not None and float(ri.quantity) != data.quantity:
        old_qty = float(ri.quantity)
        display_unit = ri.unit or ri.ingredient.standard_unit
        changes.append(f"{ri.ingredient.name} quantity changed from {old_qty} to {data.quantity}{display_unit}")
        ri.quantity = Decimal(str(data.quantity))
    if data.yield_percent is not None and float(ri.yield_percent) != data.yield_percent:
        old_yld = float(ri.yield_percent)
        changes.append(f"{ri.ingredient.name} yield changed from {old_yld}% to {data.yield_percent}%")
        ri.yield_percent = Decimal(str(data.yield_percent))
    if data.notes is not None:
        ri.notes = data.notes
    if data.sort_order is not None:
        ri.sort_order = data.sort_order

    if changes:
        db.add(RecipeChangeLog(
            recipe_id=ri.recipe_id,
            change_summary="; ".join(changes),
            user_id=user.id,
        ))

    await db.commit()
    if changes:
        await _snapshot_recipe_and_parents(ri.recipe_id, db, f"ingredient_updated: {'; '.join(changes)}")
    return {"ok": True}


@router.delete("/recipe-ingredients/{ri_id}")
async def remove_recipe_ingredient(
    ri_id: int,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    ri_result = await db.execute(
        select(RecipeIngredient)
        .options(selectinload(RecipeIngredient.ingredient))
        .where(RecipeIngredient.id == ri_id)
    )
    ri = ri_result.scalar_one_or_none()
    if not ri:
        raise HTTPException(404, "Recipe ingredient not found")

    recipe_id_for_snap = ri.recipe_id
    ing_name = ri.ingredient.name if ri.ingredient else 'unknown'
    db.add(RecipeChangeLog(
        recipe_id=recipe_id_for_snap,
        change_summary=f"Removed {ing_name}",
        user_id=user.id,
    ))
    await db.delete(ri)
    await db.commit()
    await _snapshot_recipe_and_parents(recipe_id_for_snap, db, f"ingredient_removed: {ing_name}")
    return {"ok": True}


# ── Sub-recipes ──────────────────────────────────────────────────────────────

@router.post("/{recipe_id}/sub-recipes")
async def add_sub_recipe(
    recipe_id: int,
    data: SubRecipeAdd,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    recipe = await _get_recipe(recipe_id, user.kitchen_id, db)

    if data.child_recipe_id == recipe_id:
        raise HTTPException(400, "Cannot add recipe as its own sub-recipe")

    # Circular dependency check (max 5 levels)
    cycle_check = await db.execute(
        text("""
            WITH RECURSIVE ancestors AS (
                SELECT parent_recipe_id, child_recipe_id, 1 AS depth
                FROM recipe_sub_recipes WHERE child_recipe_id = :parent_id
                UNION ALL
                SELECT rsr.parent_recipe_id, rsr.child_recipe_id, a.depth + 1
                FROM recipe_sub_recipes rsr JOIN ancestors a ON rsr.child_recipe_id = a.parent_recipe_id
                WHERE a.depth < 5
            )
            SELECT 1 FROM ancestors WHERE parent_recipe_id = :child_id LIMIT 1
        """),
        {"parent_id": recipe_id, "child_id": data.child_recipe_id},
    )
    if cycle_check.fetchone():
        raise HTTPException(400, "Adding this sub-recipe would create a circular dependency")

    sr = RecipeSubRecipe(
        parent_recipe_id=recipe_id,
        child_recipe_id=data.child_recipe_id,
        portions_needed=Decimal(str(data.portions_needed)),
        portions_needed_unit=data.portions_needed_unit,
        notes=data.notes,
        sort_order=data.sort_order,
    )
    db.add(sr)

    child = await db.execute(select(Recipe).where(Recipe.id == data.child_recipe_id))
    child_recipe = child.scalar_one_or_none()
    unit_label = data.portions_needed_unit or _get_output_unit(child_recipe) if child_recipe else "portions"
    db.add(RecipeChangeLog(
        recipe_id=recipe_id,
        change_summary=f"Added sub-recipe '{child_recipe.name if child_recipe else '?'}' ({data.portions_needed} {unit_label})",
        user_id=user.id,
    ))

    await db.commit()
    await db.refresh(sr)
    await _snapshot_recipe_and_parents(recipe_id, db, f"sub_recipe_added: {child_recipe.name if child_recipe else '?'}")
    return {"id": sr.id}


@router.patch("/recipe-sub-recipes/{sr_id}")
async def update_sub_recipe(
    sr_id: int,
    data: SubRecipeUpdateSchema,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    sr_result = await db.execute(
        select(RecipeSubRecipe).where(RecipeSubRecipe.id == sr_id)
    )
    sr = sr_result.scalar_one_or_none()
    if not sr:
        raise HTTPException(404, "Sub-recipe not found")
    changed = False
    if data.portions_needed is not None:
        sr.portions_needed = Decimal(str(data.portions_needed))
        changed = True
    if data.portions_needed_unit is not None:
        sr.portions_needed_unit = data.portions_needed_unit or None
        changed = True
    if data.notes is not None:
        sr.notes = data.notes
    if data.sort_order is not None:
        sr.sort_order = data.sort_order
    await db.commit()
    if changed:
        await _snapshot_recipe_and_parents(sr.parent_recipe_id, db, "sub_recipe_qty_updated")
    return {"ok": True}


@router.delete("/recipe-sub-recipes/{sr_id}")
async def remove_sub_recipe(
    sr_id: int,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    sr_result = await db.execute(
        select(RecipeSubRecipe).where(RecipeSubRecipe.id == sr_id)
    )
    sr = sr_result.scalar_one_or_none()
    if not sr:
        raise HTTPException(404, "Sub-recipe not found")
    parent_id = sr.parent_recipe_id
    await db.delete(sr)
    await db.commit()
    await _snapshot_recipe_and_parents(parent_id, db, "sub_recipe_removed")
    return {"ok": True}


# ── Steps ────────────────────────────────────────────────────────────────────

@router.post("/{recipe_id}/steps")
async def add_step(
    recipe_id: int,
    data: StepCreate,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    await _get_recipe(recipe_id, user.kitchen_id, db)
    step = RecipeStep(
        recipe_id=recipe_id,
        step_number=data.step_number,
        title=data.title,
        instruction=data.instruction,
        duration_minutes=data.duration_minutes,
        notes=data.notes,
    )
    db.add(step)
    await db.commit()
    await db.refresh(step)
    return {"id": step.id}


@router.patch("/recipe-steps/{step_id}")
async def update_step(
    step_id: int,
    data: StepUpdate,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    result = await db.execute(select(RecipeStep).where(RecipeStep.id == step_id))
    step = result.scalar_one_or_none()
    if not step:
        raise HTTPException(404, "Step not found")
    if data.title is not None:
        step.title = data.title or None  # empty string -> null
    if data.instruction is not None:
        step.instruction = data.instruction
    if data.step_number is not None:
        step.step_number = data.step_number
    if data.duration_minutes is not None:
        step.duration_minutes = data.duration_minutes
    if data.notes is not None:
        step.notes = data.notes
    await db.commit()
    return {"ok": True}


@router.delete("/recipe-steps/{step_id}")
async def delete_step(
    step_id: int,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    result = await db.execute(select(RecipeStep).where(RecipeStep.id == step_id))
    step = result.scalar_one_or_none()
    if not step:
        raise HTTPException(404, "Step not found")
    await db.delete(step)
    await db.commit()
    return {"ok": True}


@router.patch("/{recipe_id}/ingredients/reorder")
async def reorder_ingredients(
    recipe_id: int,
    data: IngredientReorder,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Batch reorder recipe ingredients."""
    recipe = await _get_recipe(recipe_id, user.kitchen_id, db)
    for i, ri_id in enumerate(data.ingredient_ids):
        result = await db.execute(
            select(RecipeIngredient).where(
                RecipeIngredient.id == ri_id,
                RecipeIngredient.recipe_id == recipe_id,
            )
        )
        ri = result.scalar_one_or_none()
        if ri:
            ri.sort_order = i
    await db.commit()
    return {"ok": True}


@router.patch("/{recipe_id}/sub-recipes/reorder")
async def reorder_sub_recipes(
    recipe_id: int,
    data: SubRecipeReorder,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Batch reorder sub-recipes."""
    recipe = await _get_recipe(recipe_id, user.kitchen_id, db)
    for i, sr_id in enumerate(data.sub_recipe_ids):
        result = await db.execute(
            select(RecipeSubRecipe).where(
                RecipeSubRecipe.id == sr_id,
                RecipeSubRecipe.parent_recipe_id == recipe_id,
            )
        )
        sr = result.scalar_one_or_none()
        if sr:
            sr.sort_order = i
    await db.commit()
    return {"ok": True}


@router.patch("/{recipe_id}/steps/reorder")
async def reorder_steps(
    recipe_id: int,
    data: StepReorder,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    for i, step_id in enumerate(data.step_ids):
        result = await db.execute(
            select(RecipeStep).where(RecipeStep.id == step_id, RecipeStep.recipe_id == recipe_id)
        )
        step = result.scalar_one_or_none()
        if step:
            step.step_number = i + 1
    await db.commit()
    return {"ok": True}


# ── Images ───────────────────────────────────────────────────────────────────

@router.post("/{recipe_id}/images")
async def upload_image(
    recipe_id: int,
    file: UploadFile = File(...),
    caption: Optional[str] = Query(None),
    image_type: str = Query("general"),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    recipe = await _get_recipe(recipe_id, user.kitchen_id, db)

    # Create directory
    img_dir = os.path.join(DATA_DIR, str(user.kitchen_id), "recipes")
    os.makedirs(img_dir, exist_ok=True)

    ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg"
    filename = f"{uuid.uuid4()}{ext}"
    filepath = os.path.join(img_dir, filename)

    content = await file.read()
    with open(filepath, "wb") as f:
        f.write(content)

    img = RecipeImage(
        recipe_id=recipe_id,
        image_path=filepath,
        caption=caption,
        image_type=image_type,
        uploaded_by=user.id,
    )
    db.add(img)
    await db.commit()
    await db.refresh(img)
    return {"id": img.id, "image_path": img.image_path}


@router.get("/{recipe_id}/images/{image_id}")
async def serve_image(
    recipe_id: int,
    image_id: int,
    token: Optional[str] = Query(None),
    db: AsyncSession = Depends(get_db),
):
    # img tags can't send Authorization header, so auth via query param
    if not token:
        raise HTTPException(401, "Not authenticated")
    user = await get_current_user_from_token(token, db)
    if not user:
        raise HTTPException(401, "Not authenticated")
    await _get_recipe(recipe_id, user.kitchen_id, db)
    result = await db.execute(
        select(RecipeImage).where(RecipeImage.id == image_id, RecipeImage.recipe_id == recipe_id)
    )
    img = result.scalar_one_or_none()
    if not img or not os.path.exists(img.image_path):
        raise HTTPException(404, "Image not found")
    from fastapi.responses import FileResponse
    return FileResponse(img.image_path)


@router.delete("/recipe-images/{image_id}")
async def delete_image(
    image_id: int,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    result = await db.execute(select(RecipeImage).where(RecipeImage.id == image_id))
    img = result.scalar_one_or_none()
    if not img:
        raise HTTPException(404, "Image not found")
    if os.path.exists(img.image_path):
        os.remove(img.image_path)
    await db.delete(img)
    await db.commit()
    return {"ok": True}


# ── Costing ──────────────────────────────────────────────────────────────────

def _scale_child_sub_recipes(sub_recipes: list, scale: float) -> list:
    """Recursively scale nested sub-recipe data for hierarchical display."""
    result = []
    for sr in sub_recipes:
        result.append({
            "child_recipe_id": sr["child_recipe_id"],
            "child_recipe_name": sr["child_recipe_name"],
            "batch_output_type": sr.get("batch_output_type", "portions"),
            "output_qty": sr.get("output_qty", 1),
            "output_unit": sr.get("output_unit", "portion"),
            "portions_needed": round(sr["portions_needed"] * scale, 4),
            "cost_contribution": round(sr["cost_contribution"] * scale, 4) if sr.get("cost_contribution") else None,
            "child_ingredients": [
                {
                    "ingredient_id": ci["ingredient_id"],
                    "ingredient_name": ci["ingredient_name"],
                    "quantity": round(ci["quantity"] * scale, 4),
                    "unit": ci["unit"],
                    "yield_percent": ci.get("yield_percent", 100.0),
                    "cost_recent": round(ci["cost_recent"] * scale, 4) if ci.get("cost_recent") else None,
                    "cost_min": round(ci["cost_min"] * scale, 4) if ci.get("cost_min") else None,
                    "cost_max": round(ci["cost_max"] * scale, 4) if ci.get("cost_max") else None,
                    "is_manual_price": ci.get("is_manual_price", False),
                    "has_no_price": ci.get("has_no_price", False),
                }
                for ci in sr.get("child_ingredients", [])
            ],
            "child_sub_recipes": _scale_child_sub_recipes(sr.get("child_sub_recipes", []), scale),
        })
    return result


async def _calc_recipe_cost(recipe_id: int, db: AsyncSession, scale_to: Optional[float] = None) -> dict:
    """Calculate full cost breakdown for a recipe."""
    recipe_result = await db.execute(
        select(Recipe)
        .options(
            selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient).selectinload(Ingredient.sources),
            selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe),
        )
        .where(Recipe.id == recipe_id)
    )
    recipe = recipe_result.scalar_one_or_none()
    if not recipe:
        return {}

    output_qty = _get_output_qty(recipe)
    output_unit = _get_output_unit(recipe)
    scale_factor = (scale_to / output_qty) if scale_to else 1.0

    ingredient_costs = []
    total_ing_cost = Decimal(0)
    total_ing_cost_min = Decimal(0)
    total_ing_cost_max = Decimal(0)

    for ri in recipe.ingredients:
        ing = ri.ingredient
        if not ing:
            continue

        display_unit = ri.unit or ing.standard_unit
        qty_display = float(ri.quantity) * scale_factor
        # Convert to standard unit for cost calculation
        qty_std = _convert_unit(qty_display, display_unit, ing.standard_unit)
        yld = float(ri.yield_percent) if ri.yield_percent else 100.0

        # Get all source prices
        source_prices = []
        for src in (ing.sources or []):
            if src.price_per_std_unit:
                source_prices.append({
                    "supplier_id": src.supplier_id,
                    "price_per_std_unit": float(src.price_per_std_unit),
                    "latest_invoice_date": str(src.latest_invoice_date) if src.latest_invoice_date else None,
                })

        # Effective prices
        recent_price = None
        min_price = None
        max_price = None
        if source_prices:
            prices = [sp["price_per_std_unit"] for sp in source_prices]
            min_price = min(prices)
            max_price = max(prices)
            recent_price = source_prices[0]["price_per_std_unit"]
            # Find most recent by date
            dated = [(sp.get("latest_invoice_date", ""), sp["price_per_std_unit"]) for sp in source_prices]
            dated.sort(reverse=True)
            if dated:
                recent_price = dated[0][1]
        elif ing.manual_price:
            recent_price = min_price = max_price = float(ing.manual_price)

        ing_is_free = getattr(ing, 'is_free', False)
        is_manual_price = (not source_prices and ing.manual_price is not None) if not ing_is_free else False
        has_no_price = (recent_price is None) if not ing_is_free else False

        # Apply yield adjustment
        if recent_price and yld > 0:
            recent_eff = recent_price / (yld / 100)
        else:
            recent_eff = recent_price
        if min_price and yld > 0:
            min_eff = min_price / (yld / 100)
        else:
            min_eff = min_price
        if max_price and yld > 0:
            max_eff = max_price / (yld / 100)
        else:
            max_eff = max_price

        # Cost is calculated in standard units
        cost_recent = round(qty_std * recent_eff, 4) if recent_eff else None
        cost_min = round(qty_std * min_eff, 4) if min_eff else None
        cost_max = round(qty_std * max_eff, 4) if max_eff else None

        if cost_recent:
            total_ing_cost += Decimal(str(cost_recent))
        if cost_min:
            total_ing_cost_min += Decimal(str(cost_min))
        if cost_max:
            total_ing_cost_max += Decimal(str(cost_max))

        ingredient_costs.append({
            "ingredient_id": ing.id,
            "ingredient_name": ing.name,
            "quantity": qty_display,
            "unit": display_unit,
            "yield_percent": yld,
            "recent_price": round(recent_eff, 6) if recent_eff else None,
            "min_price": round(min_eff, 6) if min_eff else None,
            "max_price": round(max_eff, 6) if max_eff else None,
            "cost_recent": cost_recent,
            "cost_min": cost_min,
            "cost_max": cost_max,
            "sources": source_prices,
            "is_manual_price": is_manual_price,
            "has_no_price": has_no_price,
        })

    # Sub-recipe costs
    sub_recipe_costs = []
    total_sub_cost = Decimal(0)
    total_sub_cost_min = Decimal(0)
    total_sub_cost_max = Decimal(0)

    for sr in recipe.sub_recipes:
        child = sr.child_recipe
        if not child:
            continue
        child_cost_data = await _calc_recipe_cost(child.id, db)
        child_total = Decimal(str(child_cost_data.get("total_cost_recent", 0) or 0))
        child_total_min = Decimal(str(child_cost_data.get("total_cost_min", 0) or 0))
        child_total_max = Decimal(str(child_cost_data.get("total_cost_max", 0) or 0))
        child_output_qty = _get_output_qty(child)
        child_output_unit = _get_output_unit(child)
        # Convert portions_needed to child output unit if different unit was used
        needed_unit = sr.portions_needed_unit or child_output_unit
        portions_needed_raw = float(sr.portions_needed) * scale_factor
        portions_needed = _convert_unit(portions_needed_raw, needed_unit, child_output_unit)
        scale_ratio = portions_needed / child_output_qty if child_output_qty else 0
        cost_contribution = float(child_total) * scale_ratio if child_total else None
        cost_contribution_min = float(child_total_min) * scale_ratio if child_total_min else None
        cost_contribution_max = float(child_total_max) * scale_ratio if child_total_max else None

        if cost_contribution:
            total_sub_cost += Decimal(str(cost_contribution))
        if cost_contribution_min:
            total_sub_cost_min += Decimal(str(cost_contribution_min))
        if cost_contribution_max:
            total_sub_cost_max += Decimal(str(cost_contribution_max))

        # Scale child ingredients by portions_needed / child_output_qty
        # Include both direct ingredients AND ingredients from the child's own sub-recipes
        child_scale = portions_needed / child_output_qty if child_output_qty else 1
        child_ingredients = []
        for ci in child_cost_data.get("ingredients", []):
            child_ingredients.append({
                "ingredient_id": ci["ingredient_id"],
                "ingredient_name": ci["ingredient_name"],
                "quantity": round(ci["quantity"] * child_scale, 4),
                "unit": ci["unit"],
                "yield_percent": ci["yield_percent"],
                "cost_recent": round(ci["cost_recent"] * child_scale, 4) if ci.get("cost_recent") else None,
                "cost_min": round(ci["cost_min"] * child_scale, 4) if ci.get("cost_min") else None,
                "cost_max": round(ci["cost_max"] * child_scale, 4) if ci.get("cost_max") else None,
                "is_manual_price": ci.get("is_manual_price", False),
                "has_no_price": ci.get("has_no_price", False),
            })
        # Build hierarchical child_sub_recipes from the child's own sub-recipes
        child_sub_recipes = _scale_child_sub_recipes(
            child_cost_data.get("sub_recipes", []), child_scale
        )

        sub_recipe_costs.append({
            "child_recipe_id": child.id,
            "child_recipe_name": child.name,
            "batch_portions": child.batch_portions,
            "batch_output_type": child.batch_output_type or "portions",
            "output_qty": child_output_qty,
            "output_unit": child_output_unit,
            "portions_needed": portions_needed,
            "cost_per_portion": float(child_total) / child_output_qty if child_total and child_output_qty else None,
            "cost_contribution": round(cost_contribution, 4) if cost_contribution else None,
            "child_ingredients": child_ingredients,
            "child_sub_recipes": child_sub_recipes,
        })

    total_cost = float(total_ing_cost + total_sub_cost)
    total_cost_min = float(total_ing_cost_min + total_sub_cost_min)
    total_cost_max = float(total_ing_cost_max + total_sub_cost_max)
    effective_output = scale_to if scale_to else output_qty
    cost_per_portion = total_cost / effective_output if effective_output and total_cost else None

    # GP calculator for dishes (gross prices incl. 20% VAT)
    gp_comparison = None
    if recipe.recipe_type == "dish" and cost_per_portion:
        vat_rate = 1.20
        gp_comparison = [
            {"gp_target": pct, "suggested_price": round(cost_per_portion / (1 - pct / 100) * vat_rate, 2)}
            for pct in [60, 65, 70, 75, 80]
        ]

    return {
        "recipe_id": recipe.id,
        "batch_portions": recipe.batch_portions or 1,
        "batch_output_type": recipe.batch_output_type or "portions",
        "output_qty": effective_output,
        "output_unit": output_unit,
        "ingredients": ingredient_costs,
        "sub_recipes": sub_recipe_costs,
        "total_cost_recent": round(total_cost, 4) if total_cost else None,
        "total_cost_min": round(total_cost_min, 4) if total_cost_min else None,
        "total_cost_max": round(total_cost_max, 4) if total_cost_max else None,
        "cost_per_portion": round(cost_per_portion, 4) if cost_per_portion else None,
        "gp_comparison": gp_comparison,
    }


@router.get("/{recipe_id}/costing")
async def get_recipe_costing(
    recipe_id: int,
    scale_to: Optional[float] = Query(None),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    recipe = await _get_recipe(recipe_id, user.kitchen_id, db)
    return await _calc_recipe_cost(recipe_id, db, scale_to)


@router.get("/{recipe_id}/cost-trend")
async def get_cost_trend(
    recipe_id: int,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    await _get_recipe(recipe_id, user.kitchen_id, db)
    result = await db.execute(
        select(RecipeCostSnapshot)
        .where(RecipeCostSnapshot.recipe_id == recipe_id)
        .order_by(RecipeCostSnapshot.snapshot_date)
    )
    snapshots = result.scalars().all()

    # Get change log entries to show what caused cost changes
    log_result = await db.execute(
        select(RecipeChangeLog)
        .where(RecipeChangeLog.recipe_id == recipe_id)
        .order_by(RecipeChangeLog.created_at)
    )
    logs = log_result.scalars().all()

    # Group changes by date for annotation
    changes_by_date: dict[str, list[str]] = {}
    for log in logs:
        if log.created_at:
            log_date = str(log.created_at.date()) if hasattr(log.created_at, 'date') else str(log.created_at)[:10]
            changes_by_date.setdefault(log_date, []).append(log.change_summary)

    return {
        "snapshots": [
            {
                "id": s.id,
                "created_at": str(s.snapshot_date),
                "cost_per_portion": float(s.cost_per_portion),
                "total_cost": float(s.total_cost),
                "trigger": s.trigger_source or "",
                "changes": changes_by_date.get(str(s.snapshot_date), []),
            }
            for s in snapshots
        ],
    }


async def snapshot_recipe_cost(recipe_id: int, db: AsyncSession, trigger_source: str = "manual_recalc"):
    """Calculate and store/upsert a cost snapshot for today."""
    cost_data = await _calc_recipe_cost(recipe_id, db)
    total_cost = cost_data.get("total_cost_recent")
    cost_per_portion = cost_data.get("cost_per_portion")
    if total_cost is None or cost_per_portion is None:
        return

    today = date.today()
    existing = await db.execute(
        select(RecipeCostSnapshot).where(
            RecipeCostSnapshot.recipe_id == recipe_id,
            RecipeCostSnapshot.snapshot_date == today,
        )
    )
    snap = existing.scalar_one_or_none()
    if snap:
        snap.cost_per_portion = Decimal(str(cost_per_portion))
        snap.total_cost = Decimal(str(total_cost))
        snap.trigger_source = trigger_source
    else:
        db.add(RecipeCostSnapshot(
            recipe_id=recipe_id,
            cost_per_portion=Decimal(str(cost_per_portion)),
            total_cost=Decimal(str(total_cost)),
            snapshot_date=today,
            trigger_source=trigger_source,
        ))


async def _snapshot_recipe_and_parents(recipe_id: int, db: AsyncSession, trigger_source: str = ""):
    """Snapshot a recipe and any parent recipes that use it as a sub-recipe.
    Also bumps updated_at so menu staleness detection picks up content changes."""
    # Bump updated_at on the recipe itself (ingredient/sub-recipe changes don't trigger onupdate)
    recipe_result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
    recipe = recipe_result.scalar_one_or_none()
    if recipe:
        recipe.updated_at = datetime.utcnow()

    await snapshot_recipe_cost(recipe_id, db, trigger_source)
    parent_result = await db.execute(
        select(RecipeSubRecipe.parent_recipe_id).where(RecipeSubRecipe.child_recipe_id == recipe_id)
    )
    for (parent_id,) in parent_result.fetchall():
        # Bump parent updated_at too
        p_result = await db.execute(select(Recipe).where(Recipe.id == parent_id))
        parent = p_result.scalar_one_or_none()
        if parent:
            parent.updated_at = datetime.utcnow()
        await snapshot_recipe_cost(parent_id, db, trigger_source)
    await db.commit()


async def snapshot_recipes_using_ingredient(
    ingredient_id: int,
    db: AsyncSession,
    trigger_source: str = "",
    price_info: dict | None = None,
    invoice_id: int | None = None,
):
    """Find all recipes using this ingredient and snapshot their costs.

    If price_info is provided ({"name", "unit", "old_price", "new_price"}),
    a RecipeChangeLog entry is created for each affected recipe.
    """
    # Direct usage
    ri_result = await db.execute(
        select(RecipeIngredient.recipe_id).where(RecipeIngredient.ingredient_id == ingredient_id)
    )
    recipe_ids = set(r[0] for r in ri_result.fetchall())

    # Also find recipes that use sub-recipes containing this ingredient (1 level)
    for rid in list(recipe_ids):
        parent_result = await db.execute(
            select(RecipeSubRecipe.parent_recipe_id).where(RecipeSubRecipe.child_recipe_id == rid)
        )
        for (parent_id,) in parent_result.fetchall():
            recipe_ids.add(parent_id)

    # Build change log message if price info available
    change_msg = None
    if price_info:
        name = price_info["name"]
        unit = price_info.get("unit", "")
        old_p = price_info.get("old_price")
        new_p = price_info["new_price"]
        unit_label = f"/{unit}" if unit else ""
        # Only log if price actually changed
        if old_p is not None and abs(new_p - old_p) > 0.000001:
            change_msg = f"{name} price changed: £{old_p:.4f}{unit_label} → £{new_p:.4f}{unit_label}"
        elif old_p is None:
            change_msg = f"{name} price set: £{new_p:.4f}{unit_label}"

    for rid in recipe_ids:
        # Bump updated_at so menu staleness detection picks up ingredient flag changes
        r_result = await db.execute(select(Recipe).where(Recipe.id == rid))
        r = r_result.scalar_one_or_none()
        if r:
            r.updated_at = datetime.utcnow()
        await snapshot_recipe_cost(rid, db, trigger_source)
        if change_msg:
            db.add(RecipeChangeLog(
                recipe_id=rid,
                change_summary=change_msg,
                user_id=None,
                source_invoice_id=invoice_id,
            ))
    if recipe_ids:
        await db.commit()


# ── Recipe Change Log ────────────────────────────────────────────────────────

@router.get("/{recipe_id}/change-log")
async def get_change_log(
    recipe_id: int,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    await _get_recipe(recipe_id, user.kitchen_id, db)
    result = await db.execute(
        select(RecipeChangeLog)
        .options(selectinload(RecipeChangeLog.user))
        .where(RecipeChangeLog.recipe_id == recipe_id)
        .order_by(RecipeChangeLog.created_at.desc())
    )
    logs = result.scalars().all()
    return [
        {
            "id": l.id,
            "change_summary": l.change_summary,
            "username": l.user.name if l.user else "",
            "created_at": str(l.created_at) if l.created_at else "",
        }
        for l in logs
    ]


@router.post("/cleanup-false-price-changes")
async def cleanup_false_price_changes(
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Remove recipe change log entries where old price equals new price (false changes)."""
    if not user.is_admin:
        raise HTTPException(status_code=403, detail="Admin only")

    import re

    # Get all "price changed" log entries for this kitchen's recipes
    result = await db.execute(
        select(RecipeChangeLog)
        .join(Recipe, RecipeChangeLog.recipe_id == Recipe.id)
        .where(
            Recipe.kitchen_id == user.kitchen_id,
            RecipeChangeLog.change_summary.like("%price changed%"),
        )
    )
    logs = result.scalars().all()

    # Pattern: "Ingredient name price changed: £0.0086/g → £0.0086/g"
    pattern = re.compile(r"price changed: £([\d.]+)(/\w+)? → £([\d.]+)(/\w+)?")
    deleted = 0
    for log in logs:
        match = pattern.search(log.change_summary)
        if match:
            old_price = float(match.group(1))
            new_price = float(match.group(3))
            if abs(old_price - new_price) < 0.000001:
                await db.delete(log)
                deleted += 1

    await db.commit()
    return {"message": f"Removed {deleted} false price change entries", "deleted": deleted}


@router.post("/backfill-invoice-references")
async def backfill_invoice_references(
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Backfill missing source_invoice_id on recipe change log entries
    by matching to cost snapshots whose trigger_source contains 'invoice #<id>'."""
    if not user.is_admin:
        raise HTTPException(status_code=403, detail="Admin only")

    import re

    # Get change log entries missing source_invoice_id for this kitchen
    result = await db.execute(
        select(RecipeChangeLog)
        .join(Recipe, RecipeChangeLog.recipe_id == Recipe.id)
        .where(
            Recipe.kitchen_id == user.kitchen_id,
            RecipeChangeLog.source_invoice_id == None,
            (RecipeChangeLog.change_summary.like("%price changed%") | RecipeChangeLog.change_summary.like("%price set%")),
        )
    )
    logs = result.scalars().all()

    if not logs:
        return {"message": "No entries missing invoice references", "updated": 0}

    # For each log, find a cost snapshot for same recipe created within 5 seconds
    invoice_re = re.compile(r"invoice #(\d+)")
    updated = 0
    for log in logs:
        if not log.created_at:
            continue
        # Look for a cost snapshot close in time with an invoice trigger_source
        snap_result = await db.execute(
            select(RecipeCostSnapshot.trigger_source).where(
                RecipeCostSnapshot.recipe_id == log.recipe_id,
                RecipeCostSnapshot.created_at.between(
                    log.created_at - timedelta(seconds=5),
                    log.created_at + timedelta(seconds=5),
                ),
                RecipeCostSnapshot.trigger_source.like("%invoice #%"),
            ).limit(1)
        )
        row = snap_result.scalar_one_or_none()
        if row:
            m = invoice_re.search(row)
            if m:
                log.source_invoice_id = int(m.group(1))
                updated += 1

    await db.commit()
    return {"message": f"Linked {updated} of {len(logs)} entries to their triggering invoice", "updated": updated, "total": len(logs)}


# ── Print / Recipe Card HTML ────────────────────────────────────────────────

@router.get("/{recipe_id}/print")
async def print_recipe(
    recipe_id: int,
    format: str = Query("full"),  # "full" | "kitchen"
    token: Optional[str] = Query(None),
    db: AsyncSession = Depends(get_db),
):
    # window.open() can't send Authorization header, so auth via query param
    if not token:
        raise HTTPException(status_code=401, detail="Not authenticated")
    user = await get_current_user_from_token(token, db)
    if not user:
        raise HTTPException(status_code=401, detail="Not authenticated")
    recipe = await _get_recipe(recipe_id, user.kitchen_id, db)
    full_data = await get_recipe(recipe_id, user, db)
    cost_data = await _calc_recipe_cost(recipe_id, db)

    # Get flags
    from api.food_flags import compute_recipe_flags
    flags = await compute_recipe_flags(recipe_id, user.kitchen_id, db)

    html = _build_recipe_html(full_data, cost_data, flags, format, recipe_id=recipe_id, token=token)
    return HTMLResponse(content=html)


def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str = "full", recipe_id: int = 0, token: str = "") -> str:
    """Generate print-optimised HTML for a recipe."""
    esc = html_escape
    name = esc(recipe_data.get("name", ""))
    recipe_type = recipe_data.get("recipe_type", "dish")
    batch = recipe_data.get("batch_portions", 1)
    batch_output_type = recipe_data.get("batch_output_type", "portions")
    output_qty = recipe_data.get("output_qty", batch)
    output_unit = recipe_data.get("output_unit", "portion")
    description = esc(recipe_data.get("description", "") or "")
    prep_time = recipe_data.get("prep_time_minutes")
    cook_time = recipe_data.get("cook_time_minutes")

    # Flag badges
    flag_html = ""
    for f in flags:
        if f.is_active:
            color = "#dc3545" if f.propagation_type == "contains" else "#28a745"
            badge_style = f"display:inline-block;padding:2px 8px;margin:2px;border-radius:12px;background:{color};color:white;font-size:12px;"
            if f.excludable_on_request:
                badge_style += "border:2px dashed white;"
            flag_html += f'<span style="{badge_style}">{esc(f.flag_code or f.flag_name)}</span>'

    # Ingredients table
    ing_rows = ""
    for ing in recipe_data.get("ingredients", []):
        cost_str = f"£{ing['cost']:.2f}" if ing.get("cost") else "-"
        ing_rows += f"""<tr>
            <td style="padding:6px 10px;border-bottom:1px solid #eee;">{esc(ing['ingredient_name'])}</td>
            <td style="padding:6px 10px;border-bottom:1px solid #eee;text-align:right;">{ing['quantity']:g}{esc(ing['unit'])}</td>
            <td style="padding:6px 10px;border-bottom:1px solid #eee;text-align:right;">{cost_str}</td>
        </tr>"""

    # Sub-recipes — match cost_data child_ingredients by child_recipe_id
    cost_sub_recipes = {sr["child_recipe_id"]: sr for sr in cost_data.get("sub_recipes", [])}
    sub_rows = ""
    for sr in recipe_data.get("sub_recipes", []):
        cost_sr = cost_sub_recipes.get(sr["child_recipe_id"], {})
        cost_str = f"£{sr['cost_contribution']:.2f}" if sr.get("cost_contribution") else "-"
        sr_unit = sr.get("output_unit", "portion")
        sr_unit_label = f"{sr['portions_needed']:g} {sr_unit}{'s' if sr['portions_needed'] != 1 and sr_unit == 'portion' else ''}"
        sub_rows += f"""<tr style="background:#f0f0f0;">
            <td style="padding:6px 10px;border-bottom:1px solid #ddd;font-weight:600;">▸ {esc(sr['child_recipe_name'])} ({sr_unit_label})</td>
            <td style="padding:6px 10px;border-bottom:1px solid #ddd;text-align:right;"></td>
            <td style="padding:6px 10px;border-bottom:1px solid #ddd;text-align:right;font-weight:600;">{cost_str}</td>
        </tr>"""
        for ci in cost_sr.get("child_ingredients", []):
            ci_cost = f"£{ci['cost_recent']:.2f}" if ci.get("cost_recent") else "-"
            sub_rows += f"""<tr style="background:#fafafa;">
            <td style="padding:4px 10px 4px 30px;border-bottom:1px solid #f0f0f0;color:#666;">↳ {esc(ci['ingredient_name'])}</td>
            <td style="padding:4px 10px;border-bottom:1px solid #f0f0f0;text-align:right;color:#666;">{ci['quantity']:g}{esc(ci['unit'])}</td>
            <td style="padding:4px 10px;border-bottom:1px solid #f0f0f0;text-align:right;color:#666;">{ci_cost}</td>
        </tr>"""

    # Steps
    steps_html = ""
    for step in recipe_data.get("steps", []):
        dur = f" ({step['duration_minutes']} min)" if step.get("duration_minutes") else ""
        title = esc(step['title']) if step.get('title') else None
        instr = esc(step['instruction'])
        if title:
            steps_html += f"<li style='margin-bottom:8px;'><strong>{title}</strong>{dur}<br/><span style='color:#555;'>{instr}</span></li>"
        else:
            steps_html += f"<li style='margin-bottom:8px;'>{instr}{f' <em>{dur}</em>' if dur else ''}</li>"

    # Cost summary
    cost_per_portion = cost_data.get("cost_per_portion")
    total_cost = cost_data.get("total_cost_recent")
    cost_summary = ""
    if format == "full" and cost_per_portion:
        cost_unit_label = f"Cost per {output_unit}" if output_unit != "portion" else "Cost per portion"
        cost_summary = f"""
        <div style="margin-top:20px;padding:12px;background:#f0f0f0;border-radius:6px;">
            <strong>{cost_unit_label}:</strong> £{cost_per_portion:.4f} |
            <strong>Total cost:</strong> £{total_cost:.2f}
        </div>"""

    # Images (plating photos for kitchen card)
    images_html = ""
    if format == "kitchen":
        plating_images = [img for img in recipe_data.get("images", []) if img.get("image_type") == "plating"]
        if not plating_images:
            plating_images = recipe_data.get("images", [])[:1]
        for img in plating_images:
            img_url = f"/api/recipes/{recipe_id}/images/{img['id']}?token={token}"
            images_html += f'<img src="{esc(img_url)}" style="max-width:300px;border-radius:8px;margin:10px 0;" />'

    time_info = ""
    if prep_time or cook_time:
        parts = []
        if prep_time:
            parts.append(f"Prep: {prep_time} min")
        if cook_time:
            parts.append(f"Cook: {cook_time} min")
        time_info = f"<p style='color:#666;'>{' | '.join(parts)}</p>"

    kitchen_font_size = "font-size:16px;" if format == "kitchen" else ""

    return f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>{name}</title>
    <style>
        body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width:800px; margin:0 auto; padding:20px; {kitchen_font_size} }}
        @media print {{
            body {{ padding: 0; }}
            .no-print {{ display: none; }}
        }}
        h1 {{ margin-bottom: 4px; }}
        table {{ width: 100%; border-collapse: collapse; margin: 10px 0; }}
        th {{ text-align: left; padding: 8px 10px; border-bottom: 2px solid #ccc; background: #f5f5f5; }}
    </style>
</head>
<body>
    <h1>{name}</h1>
    <p style="color:#666;">
        <span style="background:#e0e0e0;padding:2px 8px;border-radius:4px;font-size:13px;">{recipe_type.upper()}</span>
        {f'Yield: {output_qty:g}{output_unit}' if recipe_type == 'component' and batch_output_type == 'bulk' else (f'Batch: {batch} portions' if recipe_type == 'component' else '')}
    </p>
    {time_info}
    {f'<p>{description}</p>' if description else ''}
    <div style="margin:10px 0;">{flag_html}</div>
    {images_html}

    <h2>Ingredients</h2>
    <table>
        <thead><tr>
            <th>Ingredient</th>
            <th style="text-align:right;">Quantity</th>
            <th style="text-align:right;">Cost</th>
        </tr></thead>
        <tbody>{ing_rows}{sub_rows}</tbody>
    </table>

    {cost_summary}

    {'<h2>Method</h2><ol>' + steps_html + '</ol>' if steps_html else ''}

    {f'<p style="color:#999;font-size:12px;margin-top:30px;">Printed {date.today().strftime("%d/%m/%Y")}</p>'}
</body>
</html>"""


# ── Helpers ──────────────────────────────────────────────────────────────────

# Unit conversion factors to a common base (g for weight, ml for volume)
_UNIT_TO_BASE = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0}

# Which units are compatible (same measurement type)
_COMPATIBLE_UNITS = {
    "g": ("g", "kg"), "kg": ("g", "kg"),
    "ml": ("ml", "ltr"), "ltr": ("ml", "ltr"),
    "portion": ("portion",), "each": ("each",),
}


def _convert_unit(value: float, from_unit: str, to_unit: str) -> float:
    """Convert a value between compatible units. Returns original value if incompatible."""
    if from_unit == to_unit:
        return value
    from_base = _UNIT_TO_BASE.get(from_unit)
    to_base = _UNIT_TO_BASE.get(to_unit)
    if from_base is None or to_base is None:
        return value
    # Check compatibility
    if to_unit not in _COMPATIBLE_UNITS.get(from_unit, ()):
        return value
    return value * from_base / to_base


def _get_compatible_units(unit: str) -> list[str]:
    """Get list of compatible units for a given output unit."""
    return list(_COMPATIBLE_UNITS.get(unit, (unit,)))


def _get_output_qty(recipe) -> float:
    """Unified output quantity: bulk uses yield_qty, portioned uses batch_portions."""
    if recipe.batch_output_type == "bulk" and recipe.batch_yield_qty:
        return float(recipe.batch_yield_qty)
    return recipe.batch_portions or 1

def _get_output_unit(recipe) -> str:
    """Unified output unit label: bulk uses yield_unit, portioned uses 'portion'."""
    if recipe.batch_output_type == "bulk" and recipe.batch_yield_unit:
        return recipe.batch_yield_unit
    return "portion"

async def _get_recipe(recipe_id: int, kitchen_id: int, db: AsyncSession) -> Recipe:
    result = await db.execute(
        select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == kitchen_id)
    )
    recipe = result.scalar_one_or_none()
    if not recipe:
        raise HTTPException(404, "Recipe not found")
    return recipe
