"""
Orchestrator step functions.

Each step receives a WeeklyRun row and an open Session. Steps are idempotent:
a non-null timestamp column causes an immediate return. Steps commit their own
changes before returning.

All service imports are at module level so tests can monkeypatch via
`app.services.orchestrator.steps.<name>`.
"""
from __future__ import annotations

import html
import logging
import uuid as _uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING

from app.config import settings
from app.models import FamilyMember, FamilyProfile, Ingredient, IngredientGroceryMatch, MealPlan, MealPlanItemStatus, MealPlanVote
from app.services.approval import issue_token
from app.services.email import get_email_backend
from app.services.orchestrator.alerts import send_admin_alert
from app.services.planner.generate import generate_meal_plan
from app.services.scraper_service import ScraperService
from app.utils.units import convert_qty

from app.services.feedback_analyzer import FeedbackAnalyzer
from app.services.recipe_discovery import RecipeDiscoveryService
from app.services.recipe_ingestion import RecipeIngestionService

if TYPE_CHECKING:
    from sqlalchemy.orm import Session
    from app.models import WeeklyRun

logger = logging.getLogger(__name__)


def step_scrape(run: "WeeklyRun", db: "Session") -> None:
    if run.scraped_at is not None:
        logger.info("step_scrape: already done for %s, skipping", run.week_start_date)
        return

    service = ScraperService(db)
    last_result: dict = {}
    for attempt in (1, 2):
        last_result = service.run_scrape()
        if last_result["status"] == "success":
            run.scraped_at = datetime.now(timezone.utc)
            run.used_stale_data = False
            db.commit()
            logger.info("step_scrape: success on attempt %d", attempt)
            return
        logger.warning(
            "step_scrape: attempt %d failed: %s", attempt, last_result.get("error")
        )

    # Both attempts failed — mark stale and continue the cycle
    run.scraped_at = datetime.now(timezone.utc)
    run.used_stale_data = True
    db.commit()
    send_admin_alert(
        subject=f"Scrape failed week {run.week_start_date}",
        body=(
            f"Both scrape attempts failed. Proceeding with stale grocery data.\n"
            f"Error: {last_result.get('error')}"
        ),
    )
    logger.warning("step_scrape: both attempts failed; marked stale")


def step_generate(run: "WeeklyRun", db: "Session") -> None:
    if run.generated_at is not None:
        logger.info("step_generate: already done for %s", run.week_start_date)
        return

    try:
        result = generate_meal_plan(
            db,
            family_id=run.family_id,
            week_start_date=run.week_start_date,
        )
        run.generated_at = datetime.now(timezone.utc)
        db.commit()
        logger.info("step_generate: meal_plan %s created", result.meal_plan_id)
    except Exception as exc:
        run.error_step = "generate"
        run.error_message = str(exc)
        db.commit()
        send_admin_alert(
            subject=f"Generate failed week {run.week_start_date}",
            body=str(exc),
        )
        raise


def step_email(run: "WeeklyRun", db: "Session") -> None:
    if run.emailed_at is not None:
        logger.info("step_email: already done for %s", run.week_start_date)
        return

    plan = (
        db.query(MealPlan)
        .filter(
            MealPlan.family_profile_id == run.family_id,
            MealPlan.week_start_date == run.week_start_date,
        )
        .first()
    )
    if plan is None:
        raise RuntimeError(
            f"No MealPlan for family {run.family_id} week {run.week_start_date}"
        )

    members = (
        db.query(FamilyMember)
        .filter(
            FamilyMember.family_profile_id == run.family_id,
            FamilyMember.email.isnot(None),
        )
        .all()
    )

    stale_banner = ""
    if run.used_stale_data:
        stale_banner = (
            "<p><strong>Note:</strong> Grocery prices in this plan may be a few "
            "days old — the Friday scrape failed and stale data was used.</p>"
        )

    # Preload ingredient names for all pending items
    ing_ids: set = set()
    for _item in plan.items:
        if _item.approval_status == MealPlanItemStatus.pending and _item.recipe:
            for _ing in (_item.recipe.ingredients or []):
                if "ingredient_id" in _ing:
                    ing_ids.add(_ing["ingredient_id"])

    ingredient_names: dict = {}
    if ing_ids:
        _ing_rows = db.query(Ingredient).filter(
            Ingredient.id.in_([_uuid.UUID(str(i)) for i in ing_ids])
        ).all()
        ingredient_names = {str(r.id): r.name for r in _ing_rows}

    # Build shopping list preview once (shared across all member emails)
    all_ingredients: dict[str, tuple[str, str, str, str]] = {}
    for item in plan.items:
        if item.approval_status != MealPlanItemStatus.pending:
            continue
        for ing in (item.recipe.ingredients or [] if item.recipe else []):
            ing_name = ingredient_names.get(str(ing.get("ingredient_id", "")), ing.get("name", "unknown")).strip()
            if not ing_name or ing_name in all_ingredients:
                continue
            match = (
                db.query(IngredientGroceryMatch)
                .join(Ingredient, IngredientGroceryMatch.ingredient_id == Ingredient.id)
                .filter(Ingredient.name_lower == ing_name.lower())
                .order_by(IngredientGroceryMatch.confidence.desc())
                .first()
            )
            grocery_name = match.grocery_item.name if match and match.grocery_item else "—"
            price = (
                f"${float(match.grocery_item.current_price):.2f}"
                if match and match.grocery_item and match.grocery_item.current_price
                else "—"
            )
            all_ingredients[ing_name] = (
                ing.get("qty", ""),
                ing.get("unit", ""),
                grocery_name,
                price,
            )

    if all_ingredients:
        shop_rows = "".join(
            f"<tr><td style='padding:4px 8px'>{html.escape(name)}</td>"
            f"<td style='padding:4px 8px;color:#555'>{html.escape(str(qty))} {html.escape(str(unit))}</td>"
            f"<td style='padding:4px 8px;color:#555'>{html.escape(str(grocery))}</td>"
            f"<td style='padding:4px 8px;font-weight:bold'>{html.escape(str(price))}</td></tr>"
            for name, (qty, unit, grocery, price) in all_ingredients.items()
        )
        shopping_preview = (
            f"<hr style='margin:24px 0'>"
            f"<h3>Estimated shopping list</h3>"
            f"<table style='border-collapse:collapse;font-size:13px;width:100%'>"
            f"<thead><tr>"
            f"<th style='text-align:left;padding:4px 8px;border-bottom:1px solid #e5e7eb'>Ingredient</th>"
            f"<th style='text-align:left;padding:4px 8px;border-bottom:1px solid #e5e7eb'>Qty</th>"
            f"<th style='text-align:left;padding:4px 8px;border-bottom:1px solid #e5e7eb'>At Lucky</th>"
            f"<th style='text-align:left;padding:4px 8px;border-bottom:1px solid #e5e7eb'>Price</th>"
            f"</tr></thead><tbody>{shop_rows}</tbody></table>"
        )
    else:
        shopping_preview = ""

    backend = get_email_backend()
    for member in members:
        item_html_parts = []
        for item in plan.items:
            if item.approval_status != MealPlanItemStatus.pending:
                continue
            token = issue_token(item.id, member.id)
            vote_url = (
                f"{settings.APP_BASE_URL}/api/meals/vote/{item.id}?token={token}"
            )
            recipe_name = html.escape(
                item.recipe.name if item.recipe else str(item.recipe_id)
            )

            # Image block
            img_block = (
                f'<img src="{html.escape(item.recipe.image_url)}" '
                f'style="width:200px;height:140px;object-fit:cover;border-radius:8px;" alt="">'
                if item.recipe and item.recipe.image_url else ""
            )

            # Ingredients list
            ingredients = item.recipe.ingredients or [] if item.recipe else []
            ing_rows = "".join(
                f"<li>{html.escape(str(ing.get('qty', '')))}"
                f" {html.escape(str(ing.get('unit', '')))}"
                f" {html.escape(ingredient_names.get(str(ing.get('ingredient_id', '')), ing.get('name', 'unknown')))}</li>"
                for ing in ingredients
            )
            ing_block = (
                f"<ul style='margin:4px 0;padding-left:20px;font-size:13px;color:#555'>{ing_rows}</ul>"
                if ing_rows else ""
            )

            # Cooking instructions (collapsed by default)
            instructions = item.recipe.instructions or [] if item.recipe else []
            instr_rows = "".join(
                f"<li style='margin-bottom:4px'>{html.escape(str(step))}</li>"
                for step in instructions
            )
            instructions_block = (
                f"<details style='margin-top:8px'>"
                f"<summary style='font-size:13px;color:#374151;cursor:pointer'>"
                f"Cooking steps ({len(instructions)})</summary>"
                f"<ol style='margin:8px 0;padding-left:20px;font-size:13px;color:#555'>{instr_rows}</ol>"
                f"</details>"
                if instr_rows else ""
            )

            # Estimated cost: sum top-confidence grocery match prices ÷ servings
            est_cost_total = 0.0
            for ing in ingredients:
                ing_name = ingredient_names.get(str(ing.get("ingredient_id", "")), ing.get("name", "")).lower()
                match = (
                    db.query(IngredientGroceryMatch)
                    .join(Ingredient, IngredientGroceryMatch.ingredient_id == Ingredient.id)
                    .filter(Ingredient.name_lower == ing_name)
                    .order_by(IngredientGroceryMatch.confidence.desc())
                    .first()
                )
                if match and match.grocery_item and match.grocery_item.current_price:
                    qty = ing.get("qty", 1.0)
                    unit = ing.get("unit", "")
                    gunit = match.grocery_item.unit or ""
                    converted = convert_qty(
                        qty,
                        unit,
                        gunit,
                        ingredient_name_lower=ing_name,
                    )
                    est_cost_total += float(match.grocery_item.current_price) * float(converted)

            recipe_servings = (item.recipe.servings or 4) if item.recipe else 4
            est_cost_per_serving = est_cost_total / recipe_servings

            cost_block = (
                f"<p style='font-size:13px;color:#888'>Est. ~${est_cost_per_serving:.2f}/serving</p>"
                if est_cost_total > 0 else ""
            )

            item_html_parts.append(
                f'<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px">'
                f'{img_block}'
                f'<h3 style="margin:8px 0 4px">{recipe_name}</h3>'
                f'{ing_block}'
                f'{instructions_block}'
                f'{cost_block}'
                f'<a href="{vote_url}" style="display:inline-block;margin-top:8px;padding:8px 16px;'
                f'background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
                f'Vote on this meal</a>'
                f'</div>'
            )

        if not item_html_parts:
            continue

        email_html = (
            f"<div style='font-family:sans-serif;max-width:600px;margin:0 auto'>"
            f"<h2>This week's meal suggestions</h2>"
            f"{stale_banner}"
            f"<p>Hi {html.escape(member.name)}, please vote on this week's meals by Fri 17:00 PT. "
            f"Silence = approved. Any denial removes that meal.</p>"
            f"{''.join(item_html_parts)}"
            f"{shopping_preview}"
            f"</div>"
        )
        backend.send(
            to=member.email,
            subject=f"Meal plan for week of {run.week_start_date}",
            html=email_html,
        )
        logger.info("step_email: sent to %s", member.email)

    run.emailed_at = datetime.now(timezone.utc)
    db.commit()


def step_deadline(run: "WeeklyRun", db: "Session") -> None:
    if run.deadline_passed_at is not None:
        logger.info("step_deadline: already done for %s", run.week_start_date)
        return

    plan = (
        db.query(MealPlan)
        .filter(
            MealPlan.family_profile_id == run.family_id,
            MealPlan.week_start_date == run.week_start_date,
        )
        .first()
    )
    if plan is None:
        run.deadline_passed_at = datetime.now(timezone.utc)
        db.commit()
        return

    family = db.query(FamilyProfile).filter(FamilyProfile.id == run.family_id).first()
    policy = family.pending_approval_policy if family else "approve"

    resolved = 0
    for item in plan.items:
        if item.approval_status == MealPlanItemStatus.pending:
            item.approval_status = (
MealPlanItemStatus.approved
                if policy == "approve"
                else MealPlanItemStatus.denied
            )
            resolved += 1

    run.deadline_passed_at = datetime.now(timezone.utc)
    db.commit()
    logger.info(
        "step_deadline: resolved %d pending items with policy=%s", resolved, policy
    )


def step_finalize(run: "WeeklyRun", db: "Session") -> None:
    if run.finalized_at is not None:
        logger.info("step_finalize: already done for %s", run.week_start_date)
        return

    import uuid as _uuid_fin

    plan = (
        db.query(MealPlan)
        .filter(
            MealPlan.family_profile_id == run.family_id,
            MealPlan.week_start_date == run.week_start_date,
        )
        .first()
    )

    approved_items = [
        item
        for item in (plan.items if plan else [])
        if item.approval_status == MealPlanItemStatus.approved
    ]

    members = (
        db.query(FamilyMember)
        .filter(
            FamilyMember.family_profile_id == run.family_id,
            FamilyMember.email.isnot(None),
        )
        .all()
    )

    # Preload ingredient names for approved items
    _fin_ids: set = set()
    for _item in approved_items:
        if _item.recipe:
            for _ing in (_item.recipe.ingredients or []):
                if "ingredient_id" in _ing:
                    _fin_ids.add(_ing["ingredient_id"])
    _fin_names: dict = {}
    if _fin_ids:
        _fin_rows = db.query(Ingredient).filter(
            Ingredient.id.in_([_uuid_fin.UUID(str(i)) for i in _fin_ids])
        ).all()
        _fin_names = {str(r.id): r.name for r in _fin_rows}

    if approved_items:
        total_cost = 0.0
        sections_html = ""
        for item in approved_items:
            if not item.recipe:
                continue
            recipe_name = html.escape(item.recipe.name)
            rows_html = ""
            for ing in (item.recipe.ingredients or []):
                ing_id = str(ing.get("ingredient_id", ""))
                ing_name = _fin_names.get(ing_id, "")
                if not ing_name:
                    continue
                qty = ing.get("qty", "")
                unit = ing.get("unit", "")

                match = (
                    db.query(IngredientGroceryMatch)
                    .join(Ingredient, IngredientGroceryMatch.ingredient_id == Ingredient.id)
                    .filter(Ingredient.id == _uuid_fin.UUID(ing_id))
                    .order_by(IngredientGroceryMatch.confidence.desc())
                    .first()
                )
                if match and match.grocery_item:
                    g_item = match.grocery_item
                    lucky_name = html.escape(g_item.name or "")
                    price = float(g_item.current_price or 0)
                    qty = ing.get("qty", 1.0)
                    unit = ing.get("unit", "")
                    gunit = g_item.unit or ""
                    converted = convert_qty(
                        qty,
                        unit,
                        gunit,
                        ingredient_name_lower=ing_name,
                    )
                    total_cost += price * float(converted)
                    price_str = f"${price * float(converted):.2f}"
                else:
                    lucky_name = "—"
                    price_str = "—"

                rows_html += (
                    f"<tr>"
                    f"<td style='padding:6px 8px'>{html.escape(ing_name)}</td>"
                    f"<td style='padding:6px 8px;color:#555'>{html.escape(str(qty))} {html.escape(str(unit))}</td>"
                    f"<td style='padding:6px 8px;color:#555'>{lucky_name}</td>"
                    f"<td style='padding:6px 8px;font-weight:bold'>{price_str}</td>"
                    f"</tr>"
                )

            sections_html += (
                f"<h3 style='margin:20px 0 4px;font-size:15px'>{recipe_name}</h3>"
                f"<table style='border-collapse:collapse;width:100%;font-size:13px;margin-bottom:8px'>"
                f"<thead><tr style='background:#f3f4f6'>"
                f"<th style='text-align:left;padding:6px 8px;border-bottom:1px solid #e5e7eb'>Ingredient</th>"
                f"<th style='text-align:left;padding:6px 8px;border-bottom:1px solid #e5e7eb'>Qty</th>"
                f"<th style='text-align:left;padding:6px 8px;border-bottom:1px solid #e5e7eb'>At Lucky</th>"
                f"<th style='text-align:left;padding:6px 8px;border-bottom:1px solid #e5e7eb'>Price</th>"
                f"</tr></thead>"
                f"<tbody>{rows_html}</tbody>"
                f"</table>"
            )

        email_html = (
            f"<div style='font-family:sans-serif;max-width:600px;margin:0 auto'>"
            f"<h2>Shopping list — week of {run.week_start_date}</h2>"
            f"<p>{len(approved_items)} meal(s) approved.</p>"
            f"{sections_html}"
            f"<p style='margin-top:16px;font-weight:bold'>Estimated total: ${total_cost:.2f}</p>"
            f"</div>"
        )
    else:
        email_html = f"<p>No meals were approved for week of {run.week_start_date}.</p>"

    backend = get_email_backend()
    for member in members:
        backend.send(
            to=member.email,
            subject=f"Shopping list — week of {run.week_start_date}",
            html=email_html,
        )
        logger.info("step_finalize: shopping list sent to %s", member.email)

    run.finalized_at = datetime.now(timezone.utc)
    run.status = "completed"

    # --- Feedback-driven recipe discovery ---
    try:
        analyzer = FeedbackAnalyzer(lookback_weeks=4)
        analysis = analyzer.analyze(db, run.family_id)
        run.feedback_analysis = analysis.to_dict()

        if (
            analysis.discovery_queries and
            analysis.confidence >= 0.5
        ):
            discovery = RecipeDiscoveryService()
            candidates = discovery.discover(analysis.discovery_queries)
            if candidates:
                ingestion = RecipeIngestionService()
                added = ingestion.ingest(db, run.family_id, candidates, analysis.to_dict())
                logger.info(
                    "step_finalize: recipe discovery added %d new recipes for family %s",
                    added, run.family_id,
                )
                # ingestion deliberately does not commit so orchestrator
                # can keep everything in one transaction
                db.commit()
    except Exception as exc:
        # Discovery is best-effort; never block finalization
        logger.warning("step_finalize: recipe discovery failed: %s", exc)
    db.commit()
    logger.info("step_finalize: done, %d approved meals", len(approved_items))


def step_reminder(run: "WeeklyRun", db: "Session") -> None:
    if run.reminded_at is not None:
        logger.info("step_reminder: already done for %s", run.week_start_date)
        return

    if run.emailed_at is None:
        logger.info("step_reminder: proposal not sent yet for %s, skipping", run.week_start_date)
        return

    plan = (
        db.query(MealPlan)
        .filter(
            MealPlan.family_profile_id == run.family_id,
            MealPlan.week_start_date == run.week_start_date,
        )
        .first()
    )
    if plan is None:
        run.reminded_at = datetime.now(timezone.utc)
        db.commit()
        return

    pending_item_ids = [
        item.id
        for item in plan.items
        if item.approval_status == MealPlanItemStatus.pending
    ]
    if not pending_item_ids:
        run.reminded_at = datetime.now(timezone.utc)
        db.commit()
        return

    members = (
        db.query(FamilyMember)
        .filter(
            FamilyMember.family_profile_id == run.family_id,
            FamilyMember.email.isnot(None),
        )
        .all()
    )

    backend = get_email_backend()
    for member in members:
        voted_ids = {
            v.meal_plan_item_id
            for v in db.query(MealPlanVote)
            .filter(
                MealPlanVote.meal_plan_item_id.in_(pending_item_ids),
                MealPlanVote.family_member_id == member.id,
            )
            .all()
        }
        unvoted = [
            item
            for item in plan.items
            if item.approval_status == MealPlanItemStatus.pending
            and item.id not in voted_ids
        ]
        if not unvoted:
            continue

        item_html_parts = []
        for item in unvoted:
            token = issue_token(item.id, member.id)
            vote_url = (
                f"{settings.APP_BASE_URL}/api/meals/vote/{item.id}?token={token}"
            )
            recipe_name = html.escape(
                item.recipe.name if item.recipe else str(item.recipe_id)
            )
            item_html_parts.append(
                f'<li>{recipe_name} — <a href="{vote_url}">Vote</a></li>'
            )

        email_html = (
            f"<h2>Vote closes in 1 hour!</h2>"
            f"<p>Hi {html.escape(member.name)}, the meal plan vote closes at Fri 17:00 PT. "
            f"You haven't voted on:</p>"
            f"<ul>{''.join(item_html_parts)}</ul>"
        )
        backend.send(
            to=member.email,
            subject=f"Meal plan vote closes in 1 hour — week of {run.week_start_date}",
            html=email_html,
        )
        logger.info("step_reminder: sent to %s", member.email)

    run.reminded_at = datetime.now(timezone.utc)
    db.commit()
    logger.info("step_reminder: done for %s", run.week_start_date)
