from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import HTMLResponse
from html import escape as _html_escape
from pydantic import BaseModel, Field
from sqlalchemy import text, func
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.models import (
    MealPlan, MealPlanItem, MealPlanVote, Recipe, Ingredient,
    FamilyProfile, FamilyMember, ApprovalToken, NeverSuggest, NeverSuggestReason,
    MealPlanStatus, MealPlanItemStatus, MealType, ApprovalTokenStatus,
)
from app.schemas import (
    MealPlanResponse, MealPlanCreate,
    MealPlanItemResponse, VoteRequest, VoteResponse,
    FillEmptySlotsRequest, FillEmptySlotsResult,
    FilledSlot, FailedSlot,
)
from app.security import require_session
from app.services import approval as approval_service
from app.services.feedback_analyzer import FeedbackAnalyzer
from uuid import UUID
from typing import List, Optional
from datetime import datetime, timedelta, timezone
from datetime import date
from uuid import UUID
from typing import List, Optional
import random

router = APIRouter()


# Sprint 8 — soft-denial decay window. A "Deny this week" creates a row
# with `denial_expires_at = now() + 90d`; after that the recipe is
# eligible again. Two denials in this window promote the recipe to a
# permanent `NeverSuggest` block (user decision: hard filter).
DENIAL_DECAY_DAYS = 90


def _ensure_never_suggest_recipe(
    db: Session, family_id, recipe_id, reason: NeverSuggestReason = NeverSuggestReason.DISLIKE
) -> bool:
    """Insert a NeverSuggest row for (family, recipe) if one doesn't exist.

    Idempotent: returns True if a new row was inserted, False if one
    already existed. Used by the auto-promotion logic in deny_meal_item
    and submit_vote when a recipe is denied for the 2nd time within the
    decay window.
    """
    existing = (
        db.query(NeverSuggest)
        .filter(
            NeverSuggest.family_profile_id == family_id,
            NeverSuggest.recipe_id == recipe_id,
        )
        .first()
    )
    if existing is not None:
        return False
    db.add(NeverSuggest(
        family_profile_id=family_id,
        recipe_id=recipe_id,
        reason=reason,
    ))
    return True


def _has_prior_active_soft_denial(
    db: Session, family_id, recipe_id, current_item_id: Optional[UUID] = None
) -> bool:
    """True if there is at least one *still-active* soft denial for this
    recipe for this family. Active = approval_status='denied' AND
    denial_expires_at > now(). Excludes the current row by default so
    the auto-promotion check is correct on the first call."""
    q = (
        db.query(func.count(MealPlanItem.id))
        .join(MealPlan, MealPlanItem.meal_plan_id == MealPlan.id)
        .filter(
            MealPlan.family_profile_id == family_id,
            MealPlanItem.recipe_id == recipe_id,
            MealPlanItem.approval_status == MealPlanItemStatus.denied,
            MealPlanItem.denial_expires_at.isnot(None),
            MealPlanItem.denial_expires_at > func.now(),
        )
    )
    if current_item_id is not None:
        q = q.filter(MealPlanItem.id != current_item_id)
    return (q.scalar() or 0) > 0


def _apply_denial(
    db: Session,
    item: MealPlanItem,
    scope: str,
) -> dict:
    """Apply a denial to `item` and (if scope=never_again or this is the
    2nd denial within the decay window) promote the recipe to a permanent
    block. Returns a dict describing what happened — used by both the
    webui and the email vote paths to surface a clear toast / message.

    scope values:
      - "this_week" (default): set denial_expires_at = now() + 90d.
        If a prior active soft denial exists, promote to permanent.
      - "never_again": write a NeverSuggest row; set denial_expires_at
        to NULL (signals "permanent, no decay").
    """
    family_id = item.meal_plan.family_profile_id
    recipe_id = item.recipe_id
    promoted = False

    if scope == "never_again":
        _ensure_never_suggest_recipe(db, family_id, recipe_id, NeverSuggestReason.DISLIKE)
        item.denial_expires_at = None
        promoted = True
    else:  # this_week
        if _has_prior_active_soft_denial(db, family_id, recipe_id, current_item_id=item.id):
            # 2nd denial in 90d → promote to permanent.
            _ensure_never_suggest_recipe(db, family_id, recipe_id, NeverSuggestReason.DISLIKE)
            item.denial_expires_at = None
            promoted = True
        else:
            item.denial_expires_at = datetime.now(timezone.utc) + timedelta(days=DENIAL_DECAY_DAYS)

    item.approval_status = MealPlanItemStatus.denied
    db.commit()
    db.refresh(item)
    return {
        "item": item,
        "promoted_to_permanent": promoted,
        "scope": scope,
    }


@router.get("", response_model=Optional[MealPlanResponse])
def get_planned_meals(
    week_start: Optional[date] = Query(
        None,
        description="ISO date of the week's Monday (YYYY-MM-DD). Omit for the most recent plan.",
    ),
    db: Session = Depends(get_db),
):
    profile = db.query(FamilyProfile).first()
    if not profile:
        raise HTTPException(status_code=404, detail="Family profile not found")

    query = db.query(MealPlan).filter(MealPlan.family_profile_id == profile.id)
    if week_start is not None:
        query = query.filter(MealPlan.week_start_date == week_start)
        meal_plan = query.first()
    else:
        meal_plan = query.order_by(MealPlan.week_start_date.desc()).first()

    if not meal_plan:
        return None

    return meal_plan


@router.post("", response_model=MealPlanResponse, dependencies=[Depends(require_session)])
def create_meal_plan(meal_plan_data: MealPlanCreate, db: Session = Depends(get_db)):
    profile = db.query(FamilyProfile).first()
    if not profile:
        raise HTTPException(status_code=404, detail="Family profile not found")

    existing = db.query(MealPlan).filter(
        MealPlan.family_profile_id == profile.id,
        MealPlan.week_start_date == meal_plan_data.week_start_date
    ).first()
    if existing:
        raise HTTPException(status_code=400, detail="Meal plan for this week already exists")

    db_meal_plan = MealPlan(
        family_profile_id=profile.id,
        week_start_date=meal_plan_data.week_start_date,
        status=MealPlanStatus[meal_plan_data.status.value.upper()],
        approval_deadline=meal_plan_data.approval_deadline,
        notes=meal_plan_data.notes
    )

    db.add(db_meal_plan)
    db.flush()

    for item_data in meal_plan_data.items:
        db_item = MealPlanItem(
            meal_plan_id=db_meal_plan.id,
            recipe_id=item_data.recipe_id,
            day_of_week=item_data.day_of_week,
            meal_type=MealType[item_data.meal_type.value.upper()],
            estimated_cost=item_data.estimated_cost
        )
        db.add(db_item)

    db.commit()
    db.refresh(db_meal_plan)
    return db_meal_plan


@router.get("/{meal_plan_id}", response_model=MealPlanResponse)
def get_meal_plan(meal_plan_id: UUID, db: Session = Depends(get_db)):
    meal_plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
    if not meal_plan:
        raise HTTPException(status_code=404, detail="Meal plan not found")
    return meal_plan


@router.post("/{meal_plan_id}/lock", dependencies=[Depends(require_session)])
def lock_meal_plan(meal_plan_id: UUID, db: Session = Depends(get_db)):
    meal_plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
    if not meal_plan:
        raise HTTPException(status_code=404, detail="Meal plan not found")

    meal_plan.status = MealPlanStatus.LOCKED
    db.commit()
    return {"message": "Meal plan locked", "status": meal_plan.status.value}


_DAY_NAMES = {
    1: "Monday", 2: "Tuesday", 3: "Wednesday", 4: "Thursday",
    5: "Friday", 6: "Saturday", 7: "Sunday",
}


class VoteSubmission(BaseModel):
    """Body for POST /vote/{item_id}?token=...

    Spec body: {"vote": "approve" | "deny" | "never_again"}.

    Sprint 8 added "never_again" as a separate vote value (vs. plain
    "deny" which is the soft "this week" denial). The vote path is the
    same; the response carries `promoted_to_permanent` and `denial_scope`
    so the email confirmation page can show what was applied.
    """

    vote: str = Field(..., pattern="^(approve|deny|never_again)$")


@router.get("/vote/{item_id}", response_class=HTMLResponse)
def get_vote_page(
    item_id: UUID,
    token: str = Query(..., description="Per-voter signed token"),
    scope: Optional[str] = Query(
        None,
        pattern="^(approve|this_week|never_again)$",
        description=(
            "Sprint 8: when present, the GET is treated as a one-click "
            "direct vote from an email link. The token is consumed, the "
            "vote is recorded via submit_vote(), and a tiny confirmation "
            "page is rendered. When absent, the page is the full vote "
            "form with 3 buttons."
        ),
    ),
    db: Session = Depends(get_db),
):
    """Render the per-voter approval confirmation page.

    Verifies the signed token (no consume) and returns minimal accessible
    HTML with Approve / Deny buttons that POST to the same URL.
    """
    payload = approval_service.verify_token(token)
    if str(payload.get("item")) != str(item_id):
        raise HTTPException(status_code=400, detail="Token not valid for this meal")

    voter = (
        db.query(FamilyMember)
        .filter(FamilyMember.id == UUID(str(payload["voter"])))
        .first()
    )
    if not voter:
        raise HTTPException(status_code=404, detail="Voter not found")

    item = (
        db.query(MealPlanItem)
        .options(joinedload(MealPlanItem.recipe))
        .filter(MealPlanItem.id == item_id)
        .first()
    )
    if not item:
        raise HTTPException(status_code=404, detail="Meal plan item not found")

    recipe_name = item.recipe.name if item.recipe else "Unnamed meal"
    day_name = _DAY_NAMES.get(int(item.day_of_week), str(item.day_of_week))
    meal_type = item.meal_type.value if item.meal_type else ""

    # Token is included only inside the form action (href), never in the
    # visible body. POST is performed by JS so we can submit JSON without
    # leaving the page; non-JS users still get a usable form fallback.
    safe_voter = _html_escape(voter.name)
    safe_recipe = _html_escape(recipe_name)
    safe_day = _html_escape(day_name)
    safe_meal = _html_escape(meal_type)

    # Sprint 8: one-click direct vote (used by the email's per-button
    # links). Consume the token, record the vote via submit_vote, and
    # render a tiny confirmation page. Single-use enforcement is shared
    # with the JSON path (consume_token).
    if scope is not None:
        # Map email-link scope to the vote-payload "vote" field.
        vote_value = "approve" if scope == "approve" else scope  # "this_week" or "never_again"
        result = submit_vote(
            item_id=item_id,
            submission=VoteSubmission(vote=vote_value),
            token=token,
            db=db,
        )
        item_status = result.get("item_status", "?")
        promoted = result.get("promoted_to_permanent", False)
        if scope == "approve":
            msg = f"Approved {safe_recipe} ({safe_day})."
        elif scope == "this_week":
            if promoted:
                msg = (
                    f"Denied {safe_recipe} for this week. "
                    f"You've denied this recipe recently, so it will not be "
                    f"suggested again (permanently blocked)."
                )
            else:
                msg = (
                    f"Denied {safe_recipe} for this week. "
                    f"It will not be re-suggested for 90 days unless denied again."
                )
        else:  # never_again
            msg = (
                f"Denied {safe_recipe} permanently. "
                f"It will never be suggested again."
            )
        confirmation = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Vote recorded</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
 body {{ font-family: system-ui, sans-serif; max-width: 36rem; margin: 2rem auto;
        padding: 0 1rem; color: #111; background: #fff; line-height: 1.5; }}
 .meal {{ padding: 1rem; border: 1px solid #444; border-radius: 6px; margin: 1rem 0; }}
 .status {{ margin-top: 1rem; font-weight: bold; }}
</style>
</head>
<body>
<h1>Hi {safe_voter}, your vote was recorded</h1>
<div class="meal">
 <div><strong>{safe_recipe}</strong></div>
 <div>{safe_day} &middot; {safe_meal}</div>
</div>
<div class="status">{_html_escape(msg)}</div>
<p style="margin-top:1rem;color:#555;font-size:14px">Meal plan status: {_html_escape(str(item_status))}.</p>
</body>
</html>
"""
        return HTMLResponse(content=confirmation, status_code=200)

    action_url = f"/api/meals/vote/{item_id}?token={_html_escape(token, quote=True)}"

    existing_vote = db.query(MealPlanVote).filter(
        MealPlanVote.meal_plan_item_id == item_id,
        MealPlanVote.family_member_id == voter.id,
    ).first()

    if existing_vote:
        already_voted_html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Already Voted</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
 body {{ font-family: system-ui, sans-serif; max-width: 36rem; margin: 2rem auto;
        padding: 0 1rem; color: #111; background: #fff; line-height: 1.5; }}
 h1 {{ font-size: 1.4rem; }}
 .meal {{ padding: 1rem; border: 1px solid #444; border-radius: 6px; margin: 1rem 0; }}
 .already-voted {{ margin-top: 1rem; font-weight: bold; color: #0a6b2b; }}
</style>
</head>
<body>
<h1>Hi {safe_voter}, you already voted on this meal</h1>
<div class="meal">
 <div><strong>{safe_recipe}</strong></div>
 <div>{safe_day} &middot; {safe_meal}</div>
</div>
<div class="already-voted">Your vote has already been recorded. Thank you!</div>
</body>
</html>
"""
        return HTMLResponse(content=already_voted_html, status_code=200)

    html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Approve meal</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
 body {{ font-family: system-ui, sans-serif; max-width: 36rem; margin: 2rem auto;
        padding: 0 1rem; color: #111; background: #fff; line-height: 1.5; }}
 h1 {{ font-size: 1.4rem; }}
 .meal {{ padding: 1rem; border: 1px solid #444; border-radius: 6px; margin: 1rem 0; }}
 .actions {{ display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0; }}
 button {{ font-size: 1rem; padding: .6rem 1.2rem;
          border: 2px solid #111; border-radius: 4px; cursor: pointer; }}
 .approve {{ background: #0a6b2b; color: #fff; }}
 .deny {{ background: #b00020; color: #fff; }}
 .never {{ background: #5a0000; color: #fff; border-style: dashed; }}
 #status {{ margin-top: 1rem; font-weight: bold; }}
</style>
</head>
<body>
<h1>Hi {safe_voter}, please vote on this meal</h1>
<div class="meal">
 <div><strong>{safe_recipe}</strong></div>
 <div>{safe_day} &middot; {safe_meal}</div>
</div>
<form id="voteForm" method="post" action="{action_url}">
 <div class="actions">
  <button type="submit" name="vote" value="approve" class="approve" aria-label="Approve this meal">Approve</button>
  <button type="submit" name="vote" value="deny" class="deny" aria-label="Deny this meal for this week (will not reappear for 90 days)">Deny this week</button>
  <button type="submit" name="vote" value="never_again" class="never" aria-label="Never suggest this recipe again">Never again</button>
 </div>
</form>
<div id="status" role="status" aria-live="polite"></div>
<script>
  document.getElementById('voteForm').addEventListener('submit', async function(e) {{
    e.preventDefault();
    var btn = e.submitter || document.activeElement;
    var vote = btn && btn.value ? btn.value : 'approve';
    var resp = await fetch(this.action, {{
      method: 'POST',
      headers: {{ 'Content-Type': 'application/json' }},
      body: JSON.stringify({{ vote: vote }})
    }});
    var data = {{}};
    try {{ data = await resp.json(); }} catch (_) {{}}
    var s = document.getElementById('status');
    if (resp.ok) {{
      var msg = 'Recorded: ' + (data.item_status || vote);
      if (vote !== 'approve' && data.promoted_to_permanent) {{
        msg += '. This recipe will not be suggested again (permanently blocked).';
      }} else if (vote === 'deny') {{
        msg += '. Will not reappear for 90 days unless denied again.';
      }} else if (vote === 'never_again') {{
        msg += '. Permanently blocked.';
      }}
      s.textContent = msg;
    }} else {{
      s.textContent = 'Error: ' + (data.detail || resp.status);
    }}
  }});
</script>
</body>
</html>
"""
    return HTMLResponse(content=html, status_code=200)


@router.post("/vote/{item_id}")
def submit_vote(
    item_id: UUID,
    submission: VoteSubmission,
    token: str = Query(..., description="Per-voter signed token"),
    db: Session = Depends(get_db),
):
    """Record a per-voter vote and apply the approval rule.

    - Single-use enforcement lives in `approval_service.consume_token`.
    - Approval rule: any deny (any scope) -> item.denied; all-approve ->
      item.approved; otherwise pending (waiting on remaining voters).
    - Sprint 8: a `deny` (or `never_again`) vote also flows through
      `_apply_denial` which may set `denial_expires_at` or promote the
      recipe to a permanent `NeverSuggest` block (auto-escalation after
      the 2nd denial in 90d).
    """
    voter = approval_service.consume_token(db, token, item_id)

    item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
    if not item:
        raise HTTPException(status_code=404, detail="Meal plan item not found")

    vote_bool = submission.vote == "approve"
    denial_scope: Optional[str] = None
    promoted = False

    if submission.vote == "never_again":
        denial_scope = "never_again"
    elif submission.vote == "deny":
        denial_scope = "this_week"

    db.add(MealPlanVote(
        meal_plan_item_id=item_id,
        family_member_id=voter.id,
        vote=vote_bool,
        denial_scope=denial_scope,
    ))

    # Apply the denial (Sprint 8). For approve votes, this is a no-op
    # other than the rule's effect on approval_status below.
    if vote_bool is False and item.recipe_id is not None:
        result = _apply_denial(db, item, scope=denial_scope or "this_week")
        promoted = result["promoted_to_permanent"]
    else:
        # Approve path: keep existing approval-rule logic.
        pass

    # Approval rule: count electorate (all family members on this profile)
    # vs votes recorded so far.
    profile_id = item.meal_plan.family_profile_id
    electorate_ids = {
        m.id for m in db.query(FamilyMember)
        .filter(FamilyMember.family_profile_id == profile_id)
        .all()
    }
    votes = db.query(MealPlanVote).filter(
        MealPlanVote.meal_plan_item_id == item_id,
    ).all()

    if any(v.vote is False for v in votes):
        item.approval_status = MealPlanItemStatus.denied
    elif electorate_ids and {v.family_member_id for v in votes} >= electorate_ids:
        item.approval_status = MealPlanItemStatus.approved
    else:
        item.approval_status = MealPlanItemStatus.pending

    db.commit()
    return {
        "status": "recorded",
        "item_status": item.approval_status.value,
        "denial_scope": denial_scope,
        "promoted_to_permanent": promoted,
    }


@router.get("/items/{item_id}", response_model=MealPlanItemResponse)
def get_meal_item(item_id: UUID, db: Session = Depends(get_db)):
    item = db.query(MealPlanItem).options(joinedload(MealPlanItem.recipe)).filter(
        MealPlanItem.id == item_id
    ).first()
    if not item:
        raise HTTPException(status_code=404, detail="Meal plan item not found")

    # Enrich ingredient names from the Ingredient table
    if item.recipe and item.recipe.ingredients:
        ing_ids = [ing.get("ingredient_id") for ing in item.recipe.ingredients if ing.get("ingredient_id")]
        if ing_ids:
            ingredients = db.query(Ingredient).filter(Ingredient.id.in_(ing_ids)).all()
            name_map = {str(i.id): i.name for i in ingredients}
            for ing in item.recipe.ingredients:
                ing_id = str(ing.get("ingredient_id", ""))
                if ing_id in name_map and not ing.get("name"):
                    ing["name"] = name_map[ing_id]

    return item


@router.post("/items/{item_id}/swap", dependencies=[Depends(require_session)])
def swap_meal_item(item_id: UUID, new_recipe_id: UUID, db: Session = Depends(get_db)):
    item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
    if not item:
        raise HTTPException(status_code=404, detail="Meal plan item not found")

    new_recipe = db.query(Recipe).filter(Recipe.id == new_recipe_id).first()
    if not new_recipe:
        raise HTTPException(status_code=404, detail="New recipe not found")

    item.recipe_id = new_recipe_id
    item.approval_status = MealPlanItemStatus.pending
    item.denial_reason = None
    item.denial_details = None
    # Sprint 8: swapping to a new recipe clears any prior soft-deny
    # window. The new recipe is a different recipe_id so the prior
    # denial wouldn't apply anyway, but the new row starts fresh.
    item.denial_expires_at = None

    db.commit()
    return {"message": "Meal swapped", "item": item}


@router.post("/items/{item_id}/approve")
def approve_meal_item(item_id: UUID, db: Session = Depends(get_db)):
    """Directly approve a meal plan item from the dashboard."""
    item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
    if not item:
        raise HTTPException(status_code=404, detail="Meal plan item not found")
    item.approval_status = MealPlanItemStatus.approved
    db.commit()
    return {"message": "Meal approved", "item": item}


@router.post("/items/{item_id}/deny")
def deny_meal_item(
    item_id: UUID,
    scope: str = Query(
        "this_week",
        pattern="^(this_week|never_again)$",
        description=(
            "Sprint 8: 'this_week' (default) sets denial_expires_at = now()+90d. "
            "If a prior active soft denial exists for the same recipe, the "
            "recipe is auto-promoted to a permanent NeverSuggest block. "
            "'never_again' always writes a NeverSuggest row and clears "
            "denial_expires_at (no decay)."
        ),
    ),
    db: Session = Depends(get_db),
):
    """Directly deny a meal plan item from the dashboard.

    Sprint 8: per the user's policy decision, two denials in the past
    90 days (or any explicit "never_again") promote the recipe to a
    permanent `NeverSuggest` block. The function returns the standard
    `MealPlanItemResponse` plus a `promoted_to_permanent` boolean so
    the frontend can show a clear toast ("Denied + won't suggest again").
    """
    item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
    if not item:
        raise HTTPException(status_code=404, detail="Meal plan item not found")
    if item.recipe_id is None:
        # Defensive: an item without a recipe can't be blocked by recipe.
        item.approval_status = MealPlanItemStatus.denied
        item.denial_expires_at = None
        db.commit()
        return {
            "message": "Meal denied",
            "item": item,
            "promoted_to_permanent": False,
            "scope": scope,
        }
    result = _apply_denial(db, item, scope=scope)
    return {
        "message": "Meal denied",
        "item": result["item"],
        "promoted_to_permanent": result["promoted_to_permanent"],
        "scope": result["scope"],
    }


@router.delete("/items/{item_id}")
def delete_meal_item(item_id: UUID, db: Session = Depends(get_db)):
    """Delete a meal plan item entirely."""
    item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
    if not item:
        raise HTTPException(status_code=404, detail="Meal plan item not found")
    db.delete(item)
    db.commit()
    return {"message": "Meal deleted"}


@router.post("/{meal_plan_id}/generate-item")
def generate_single_item(
    meal_plan_id: UUID,
    day_of_week: int = Query(..., ge=1, le=7),
    meal_type: str = Query(...),
    db: Session = Depends(get_db),
):
    """Generate a single meal for an empty slot."""
    plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
    if not plan:
        raise HTTPException(status_code=404, detail="Meal plan not found")

    # Verify slot is empty
    existing = (
        db.query(MealPlanItem)
        .filter(
            MealPlanItem.meal_plan_id == meal_plan_id,
            MealPlanItem.day_of_week == day_of_week,
            MealPlanItem.meal_type == meal_type,
        )
        .first()
    )
    if existing:
        raise HTTPException(status_code=400, detail="Slot already occupied")

    # Get all recipes, score them, pick the best not already in this plan
    all_recipes = db.query(Recipe).all()
    used_ids = {i.recipe_id for i in plan.items if i.recipe_id is not None}

    # Simple heuristic: pick a random un-used recipe (we can improve scoring later)
    available = [r for r in all_recipes if r.id not in used_ids]
    if not available:
        available = all_recipes  # reuse if all recipes are in play

    recipe = random.choice(available)

    new_item = MealPlanItem(
        meal_plan_id=meal_plan_id,
        recipe_id=recipe.id,
        day_of_week=day_of_week,
        meal_type=MealType[meal_type.upper()],
        approval_status=MealPlanItemStatus.pending,
    )
    db.add(new_item)
    db.commit()
    db.refresh(new_item)
    return {"message": "Meal generated", "item": new_item}


@router.post("/{meal_plan_id}/fill-empty-slots", response_model=FillEmptySlotsResult)
def fill_empty_slots(
    meal_plan_id: UUID,
    payload: FillEmptySlotsRequest,
    db: Session = Depends(get_db),
):
    """Fill every empty slot in the plan whose meal_type is in the
    request's `meal_types`. Returns a per-slot report (filled vs
    failed) so the UI can show "12 of 21 filled, 9 failed — recipe
    library exhausted".

    Failure model: per-slot. The endpoint never aborts mid-batch on
    a single failure; it commits what succeeded and reports the
    rest. This matches the user's chosen model (partial-success
    with detailed report).
    """
    plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
    if not plan:
        raise HTTPException(status_code=404, detail="Meal plan not found")

    # Normalise and validate the requested meal_types.
    requested: list[str] = []
    for mt in payload.meal_types:
        try:
            canonical = MealType[mt.upper()].value
        except KeyError:
            return FillEmptySlotsResult(
                filled=[],
                failed=[FailedSlot(day_of_week=0, meal_type=mt, reason=f"Unknown meal_type: {mt}")],
            )
        if canonical not in requested:
            requested.append(canonical)

    if not requested:
        return FillEmptySlotsResult(filled=[], failed=[])

    all_recipes = db.query(Recipe).all()
    if not all_recipes:
        # No recipes at all — every requested slot fails.
        return FillEmptySlotsResult(
            filled=[],
            failed=[
                FailedSlot(day_of_week=d, meal_type=mt, reason="No recipes available")
                for d in range(1, 8)
                for mt in requested
            ],
        )

    used_ids: set = {i.recipe_id for i in plan.items if i.recipe_id is not None}

    filled: list[FilledSlot] = []
    failed: list[FailedSlot] = []

    for day in range(1, 8):
        for mt in requested:
            # Skip already-occupied slots.
            existing = (
                db.query(MealPlanItem)
                .filter(
                    MealPlanItem.meal_plan_id == meal_plan_id,
                    MealPlanItem.day_of_week == day,
                    MealPlanItem.meal_type == mt,
                )
                .first()
            )
            if existing:
                continue  # not a failure, just nothing to do

            # Pick a recipe; prefer un-used, fall back to any.
            available = [r for r in all_recipes if r.id not in used_ids]
            pool = available if available else all_recipes
            recipe = random.choice(pool)

            new_item = MealPlanItem(
                meal_plan_id=meal_plan_id,
                recipe_id=recipe.id,
                day_of_week=day,
                meal_type=MealType[mt.upper()],
                approval_status=MealPlanItemStatus.pending,
            )
            db.add(new_item)
            try:
                db.flush()
                used_ids.add(recipe.id)
                filled.append(FilledSlot(
                    day_of_week=day,
                    meal_type=mt,
                    item=MealPlanItemResponse.model_validate(new_item),
                ))
            except Exception as exc:
                db.rollback()
                used_ids = {i.recipe_id for i in plan.items if i.recipe_id is not None}
                failed.append(FilledSlot if False else FailedSlot(
                    day_of_week=day,
                    meal_type=mt,
                    reason=str(exc) or "Insert failed",
                ))

    db.commit()
    return FillEmptySlotsResult(filled=filled, failed=failed)


@router.put("/items/{item_id}/move")
def move_meal_item(
    item_id: UUID,
    new_day_of_week: int,
    new_meal_type: str,
    db: Session = Depends(get_db),
):
    """Move a meal to a new day/type slot.

    If the target slot is occupied by another item in the same meal plan,
    swap the two items.
    """
    item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
    if not item:
        raise HTTPException(status_code=404, detail="Meal plan item not found")

    plan_id = item.meal_plan_id
    conflict = (
        db.query(MealPlanItem)
        .filter(
            MealPlanItem.meal_plan_id == plan_id,
            MealPlanItem.day_of_week == new_day_of_week,
            MealPlanItem.meal_type == new_meal_type,
            MealPlanItem.id != item_id,
        )
        .first()
    )

    if conflict:
        # Atomic swap via single SQL CASE statement — no intermediate
        # unique-constraint violations.
        db.execute(
            text(
                """
                UPDATE meal_plan_item
                SET day_of_week = CASE
                    WHEN id = :item_id THEN :new_day
                    WHEN id = :conflict_id THEN :old_day
                END,
                meal_type = CASE
                    WHEN id = :item_id THEN :new_type
                    WHEN id = :conflict_id THEN :old_type
                END
                WHERE id IN (:item_id, :conflict_id)
                """
            ),
            {
                "item_id": str(item_id),
                "conflict_id": str(conflict.id),
                "new_day": new_day_of_week,
                "new_type": new_meal_type,
                "old_day": item.day_of_week,
                "old_type": item.meal_type,
            },
        )
        db.commit()
        db.refresh(item)
        return {"message": "Meal swapped", "item": item}

    item.day_of_week = new_day_of_week
    item.meal_type = new_meal_type

    db.commit()
    db.refresh(item)
    return {"message": "Meal moved", "item": item}


# ── Dashboard badge: newly discovered recipes ─────────────────────────


@router.get("/dashboard/discovered-count")
def discovered_count(db: Session = Depends(get_db)):
    """Return number of recipes discovered in the last 7 days for the current family."""
    profile = db.query(FamilyProfile).first()
    if not profile:
        raise HTTPException(status_code=404, detail="Family profile not found")

    since = datetime.utcnow() - timedelta(days=7)
    count = (
        db.query(Recipe)
        .filter(
            Recipe.family_profile_id == profile.id,
            Recipe.external_source.isnot(None),
            Recipe.created_at >= since,
        )
        .count()
    )
    return {"discovered_count": count}


@router.get("/dashboard/discovery-insights")
def discovery_insights(db: Session = Depends(get_db)):
    """Return latest feedback analysis and top signals."""
    profile = db.query(FamilyProfile).first()
    if not profile:
        raise HTTPException(status_code=404, detail="Family profile not found")

    latest_run = (
        db.query(WeeklyRun)
        .filter(WeeklyRun.family_id == profile.id)
        .order_by(WeeklyRun.week_start_date.desc())
        .first()
    )
    if not latest_run or not latest_run.feedback_analysis:
        return {"has_analysis": False}

    analysis = latest_run.feedback_analysis
    return {
        "has_analysis": True,
        "confidence": analysis.get("confidence", 0),
        "positive_signals": analysis.get("positive_signals", []),
        "negative_signals": analysis.get("negative_signals", []),
        "top_rated_recipes": analysis.get("top_rated_recipes", []),
    }