"""Tests for the Phase 5 weekly orchestration."""
import pytest
from datetime import date, datetime, timezone
from uuid import uuid4

pytestmark = pytest.mark.requires_postgres

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
WEEK = date(2026, 5, 8)  # a Friday


@pytest.fixture()
def family(db):
    from app.models import FamilyProfile
    fp = FamilyProfile(
        id=uuid4(),
        name="Test Family",
        household_size=4,
        adult_count=2,
        child_count=2,
        pending_approval_policy="approve",
    )
    db.add(fp)
    db.flush()
    return fp


@pytest.fixture()
def member(db, family):
    from app.models import FamilyMember, FamilyMemberRole
    m = FamilyMember(
        id=uuid4(),
        family_profile_id=family.id,
        name="Alice",
        email="alice@example.com",
        role=FamilyMemberRole.ADULT,
    )
    db.add(m)
    db.flush()
    return m


@pytest.fixture()
def weekly_run(db, family):
    from app.models import WeeklyRun
    r = WeeklyRun(family_id=family.id, week_start_date=WEEK, status="pending")
    db.add(r)
    db.flush()
    return r


@pytest.fixture()
def weekly_run_scraped(db, family):
    from app.models import WeeklyRun
    r = WeeklyRun(
        family_id=family.id,
        week_start_date=WEEK,
        status="running",
        scraped_at=datetime.now(timezone.utc),
    )
    db.add(r)
    db.flush()
    return r


@pytest.fixture()
def weekly_run_generated(db, family):
    from app.models import WeeklyRun
    r = WeeklyRun(
        family_id=family.id,
        week_start_date=WEEK,
        status="running",
        scraped_at=datetime.now(timezone.utc),
        generated_at=datetime.now(timezone.utc),
    )
    db.add(r)
    db.flush()
    return r


@pytest.fixture()
def meal_plan(db, family):
    from app.models import MealPlan, MealPlanStatus
    mp = MealPlan(
        id=uuid4(),
        family_profile_id=family.id,
        week_start_date=WEEK,
        status=MealPlanStatus.PENDING_APPROVAL,
    )
    db.add(mp)
    db.flush()
    return mp


@pytest.fixture()
def pending_item(db, meal_plan):
    from app.models import MealPlanItem, MealPlanItemStatus, MealType, Recipe
    recipe = Recipe(
        id=uuid4(),
        name="Test Pasta",
        servings=4,
        ingredients=[{"name": "pasta", "qty": "400", "unit": "g"}],
        instructions=["boil water", "cook pasta"],
    )
    db.add(recipe)
    db.flush()
    item = MealPlanItem(
        id=uuid4(),
        meal_plan_id=meal_plan.id,
        recipe_id=recipe.id,
        day_of_week=5,
        meal_type=MealType.DINNER,
        approval_status=MealPlanItemStatus.pending,
    )
    db.add(item)
    db.flush()
    return item


@pytest.fixture()
def weekly_run_emailed(db, family):
    from app.models import WeeklyRun
    r = WeeklyRun(
        family_id=family.id,
        week_start_date=WEEK,
        status="running",
        scraped_at=datetime.now(timezone.utc),
        generated_at=datetime.now(timezone.utc),
        emailed_at=datetime.now(timezone.utc),
    )
    db.add(r)
    db.flush()
    return r


@pytest.fixture()
def weekly_run_reminded(db, family):
    from app.models import WeeklyRun
    r = WeeklyRun(
        family_id=family.id,
        week_start_date=WEEK,
        status="running",
        scraped_at=datetime.now(timezone.utc),
        generated_at=datetime.now(timezone.utc),
        emailed_at=datetime.now(timezone.utc),
        reminded_at=datetime.now(timezone.utc),
    )
    db.add(r)
    db.flush()
    return r


# ---------------------------------------------------------------------------
# alerts.py tests
# ---------------------------------------------------------------------------

def test_send_admin_alert_noop_when_email_unset(monkeypatch):
    monkeypatch.setattr(
        "app.services.orchestrator.alerts.settings",
        type("S", (), {"ADMIN_EMAIL": ""})(),
    )
    from app.services.orchestrator.alerts import send_admin_alert
    send_admin_alert("test subject", "test body")  # must not raise


def test_send_admin_alert_calls_backend(monkeypatch):
    sent = []

    class FakeBackend:
        def send(self, **kwargs):
            sent.append(kwargs)

    monkeypatch.setattr(
        "app.services.orchestrator.alerts.settings",
        type("S", (), {"ADMIN_EMAIL": "admin@example.com"})(),
    )
    monkeypatch.setattr(
        "app.services.orchestrator.alerts.get_email_backend",
        lambda: FakeBackend(),
    )
    from app.services.orchestrator.alerts import send_admin_alert
    send_admin_alert("boom", "details here")
    assert len(sent) == 1
    assert "[MealPlanner ALERT] boom" in sent[0]["subject"]
    assert sent[0]["to"] == "admin@example.com"


# ---------------------------------------------------------------------------
# step_scrape tests
# ---------------------------------------------------------------------------

def test_step_scrape_idempotent(db, weekly_run_scraped):
    original_ts = weekly_run_scraped.scraped_at
    from app.services.orchestrator.steps import step_scrape
    step_scrape(weekly_run_scraped, db)
    assert weekly_run_scraped.scraped_at == original_ts


def test_step_scrape_success(db, weekly_run, monkeypatch):
    monkeypatch.setattr(
        "app.services.orchestrator.steps.ScraperService.run_scrape",
        lambda self, **kw: {"status": "success", "items_scraped": 42},
    )
    from app.services.orchestrator.steps import step_scrape
    step_scrape(weekly_run, db)
    assert weekly_run.scraped_at is not None
    assert weekly_run.used_stale_data is False


def test_step_scrape_both_fail_marks_stale(db, weekly_run, monkeypatch):
    monkeypatch.setattr(
        "app.services.orchestrator.steps.ScraperService.run_scrape",
        lambda self, **kw: {"status": "failed", "error": "timeout"},
    )
    monkeypatch.setattr(
        "app.services.orchestrator.steps.send_admin_alert",
        lambda subject, body: None,
    )
    from app.services.orchestrator.steps import step_scrape
    step_scrape(weekly_run, db)
    assert weekly_run.used_stale_data is True
    assert weekly_run.scraped_at is not None


# ---------------------------------------------------------------------------
# step_generate tests
# ---------------------------------------------------------------------------

def test_step_generate_idempotent(db, weekly_run_generated):
    original_ts = weekly_run_generated.generated_at
    from app.services.orchestrator.steps import step_generate
    step_generate(weekly_run_generated, db)
    assert weekly_run_generated.generated_at == original_ts


def test_step_generate_creates_plan(db, weekly_run_scraped, monkeypatch):
    from uuid import uuid4
    from app.services.planner.types import GenerationResult

    monkeypatch.setattr(
        "app.services.orchestrator.steps.generate_meal_plan",
        lambda db, **kw: GenerationResult(
            meal_plan_id=uuid4(),
            selected=[],
            feasible_count=0,
            rejected_summary={},
            set_score=0.0,
        ),
    )
    from app.services.orchestrator.steps import step_generate
    step_generate(weekly_run_scraped, db)
    assert weekly_run_scraped.generated_at is not None


def test_step_generate_records_error_on_failure(db, weekly_run_scraped, monkeypatch):
    monkeypatch.setattr(
        "app.services.orchestrator.steps.generate_meal_plan",
        lambda db, **kw: (_ for _ in ()).throw(RuntimeError("no recipes")),
    )
    monkeypatch.setattr(
        "app.services.orchestrator.steps.send_admin_alert",
        lambda subject, body: None,
    )
    from app.services.orchestrator.steps import step_generate
    with pytest.raises(RuntimeError):
        step_generate(weekly_run_scraped, db)
    assert weekly_run_scraped.error_step == "generate"


# ---------------------------------------------------------------------------
# step_email tests
# ---------------------------------------------------------------------------

def test_step_email_idempotent(db, weekly_run_emailed):
    original_ts = weekly_run_emailed.emailed_at
    from app.services.orchestrator.steps import step_email
    step_email(weekly_run_emailed, db)
    assert weekly_run_emailed.emailed_at == original_ts


def test_step_email_sends_per_member(db, weekly_run_generated, meal_plan, pending_item, member, monkeypatch):
    sent = []

    class FakeBackend:
        def send(self, **kwargs):
            sent.append(kwargs)

    monkeypatch.setattr(
        "app.services.orchestrator.steps.get_email_backend",
        lambda: FakeBackend(),
    )
    from app.services.orchestrator.steps import step_email
    step_email(weekly_run_generated, db)
    assert weekly_run_generated.emailed_at is not None
    assert len(sent) == 1
    assert sent[0]["to"] == "alice@example.com"
    assert str(WEEK) in sent[0]["subject"]


def test_step_email_stale_banner(db, weekly_run_generated, meal_plan, pending_item, member, monkeypatch):
    weekly_run_generated.used_stale_data = True
    sent = []

    class FakeBackend:
        def send(self, **kwargs):
            sent.append(kwargs)

    monkeypatch.setattr(
        "app.services.orchestrator.steps.get_email_backend",
        lambda: FakeBackend(),
    )
    from app.services.orchestrator.steps import step_email
    step_email(weekly_run_generated, db)
    assert "prices" in sent[0]["html"].lower() or "stale" in sent[0]["html"].lower()


def test_step_email_escapes_recipe_name(
    db, weekly_run_generated, meal_plan, pending_item, member, monkeypatch
):
    pending_item.recipe.name = "<script>alert('xss')</script>"
    db.flush()

    sent = []

    class FakeBackend:
        def send(self, **kwargs):
            sent.append(kwargs)

    monkeypatch.setattr(
        "app.services.orchestrator.steps.get_email_backend",
        lambda: FakeBackend(),
    )
    from app.services.orchestrator.steps import step_email
    step_email(weekly_run_generated, db)
    assert len(sent) == 1
    assert "<script>" not in sent[0]["html"]
    assert "&lt;script&gt;" in sent[0]["html"]


# ── step_reminder ──────────────────────────────────────────────────────────


def test_step_reminder_idempotent(db, weekly_run_reminded):
    original_ts = weekly_run_reminded.reminded_at
    from app.services.orchestrator.steps import step_reminder
    step_reminder(weekly_run_reminded, db)
    assert weekly_run_reminded.reminded_at == original_ts


def test_step_reminder_skips_when_not_emailed(db, weekly_run_generated, monkeypatch):
    sent = []

    class FakeBackend:
        def send(self, **kwargs):
            sent.append(kwargs)

    monkeypatch.setattr(
        "app.services.orchestrator.steps.get_email_backend",
        lambda: FakeBackend(),
    )
    from app.services.orchestrator.steps import step_reminder
    step_reminder(weekly_run_generated, db)
    # emailed_at is None on weekly_run_generated — should be a no-op
    assert len(sent) == 0
    assert weekly_run_generated.reminded_at is None


def test_step_reminder_sends_to_non_voter(
    db, weekly_run_emailed, meal_plan, pending_item, member, monkeypatch
):
    sent = []

    class FakeBackend:
        def send(self, **kwargs):
            sent.append(kwargs)

    monkeypatch.setattr(
        "app.services.orchestrator.steps.get_email_backend",
        lambda: FakeBackend(),
    )
    from app.services.orchestrator.steps import step_reminder
    step_reminder(weekly_run_emailed, db)
    assert weekly_run_emailed.reminded_at is not None
    assert len(sent) == 1
    assert sent[0]["to"] == "alice@example.com"
    assert "1 hour" in sent[0]["subject"].lower() or "closes" in sent[0]["subject"].lower()


def test_step_reminder_skips_voter(
    db, weekly_run_emailed, meal_plan, pending_item, member, monkeypatch
):
    from app.models import MealPlanVote
    vote = MealPlanVote(
        meal_plan_item_id=pending_item.id,
        family_member_id=member.id,
        vote=True,
    )
    db.add(vote)
    db.flush()

    sent = []

    class FakeBackend:
        def send(self, **kwargs):
            sent.append(kwargs)

    monkeypatch.setattr(
        "app.services.orchestrator.steps.get_email_backend",
        lambda: FakeBackend(),
    )
    from app.services.orchestrator.steps import step_reminder
    step_reminder(weekly_run_emailed, db)
    # member already voted — no reminder
    assert len(sent) == 0
    assert weekly_run_emailed.reminded_at is not None


def test_step_reminder_no_sends_when_all_voted(
    db, weekly_run_emailed, meal_plan, pending_item, member, monkeypatch
):
    from app.models import MealPlanVote
    # Member has voted on the only pending item
    vote = MealPlanVote(
        meal_plan_item_id=pending_item.id,
        family_member_id=member.id,
        vote=True,
    )
    db.add(vote)
    db.flush()

    sent = []

    class FakeBackend:
        def send(self, **kwargs):
            sent.append(kwargs)

    monkeypatch.setattr(
        "app.services.orchestrator.steps.get_email_backend",
        lambda: FakeBackend(),
    )
    from app.services.orchestrator.steps import step_reminder
    step_reminder(weekly_run_emailed, db)
    # All items voted on — no emails sent, but reminded_at still stamped
    assert len(sent) == 0
    assert weekly_run_emailed.reminded_at is not None


# ---------------------------------------------------------------------------
# step_deadline tests
# ---------------------------------------------------------------------------

def test_step_deadline_idempotent(db, weekly_run_emailed):
    weekly_run_emailed.deadline_passed_at = datetime.now(timezone.utc)
    db.flush()
    original_ts = weekly_run_emailed.deadline_passed_at
    from app.services.orchestrator.steps import step_deadline
    step_deadline(weekly_run_emailed, db)
    assert weekly_run_emailed.deadline_passed_at == original_ts


def test_step_deadline_resolves_pending_to_approved(db, weekly_run_emailed, meal_plan, pending_item, family):
    from app.models import MealPlanItemStatus
    from app.services.orchestrator.steps import step_deadline
    family.pending_approval_policy = "approve"
    db.flush()
    step_deadline(weekly_run_emailed, db)
    db.refresh(pending_item)
    assert pending_item.approval_status == MealPlanItemStatus.approved
    assert weekly_run_emailed.deadline_passed_at is not None


def test_step_deadline_resolves_pending_to_denied(db, weekly_run_emailed, meal_plan, pending_item, family):
    from app.models import MealPlanItemStatus
    from app.services.orchestrator.steps import step_deadline
    family.pending_approval_policy = "deny"
    db.flush()
    step_deadline(weekly_run_emailed, db)
    db.refresh(pending_item)
    assert pending_item.approval_status == MealPlanItemStatus.denied


# ---------------------------------------------------------------------------
# step_finalize tests
# ---------------------------------------------------------------------------

@pytest.fixture()
def weekly_run_deadline_passed(db, family):
    from app.models import WeeklyRun
    r = WeeklyRun(
        family_id=family.id,
        week_start_date=WEEK,
        status="running",
        scraped_at=datetime.now(timezone.utc),
        generated_at=datetime.now(timezone.utc),
        emailed_at=datetime.now(timezone.utc),
        deadline_passed_at=datetime.now(timezone.utc),
    )
    db.add(r)
    db.flush()
    return r


@pytest.fixture()
def approved_item(db, meal_plan):
    from app.models import MealPlanItem, MealPlanItemStatus, MealType, Recipe
    recipe = Recipe(
        id=uuid4(),
        name="Approved Chicken",
        servings=4,
        ingredients=[{"name": "chicken", "qty": "500", "unit": "g"}],
        instructions=["cook it"],
    )
    db.add(recipe)
    db.flush()
    item = MealPlanItem(
        id=uuid4(),
        meal_plan_id=meal_plan.id,
        recipe_id=recipe.id,
        day_of_week=6,
        meal_type=MealType.DINNER,
        approval_status=MealPlanItemStatus.approved,
    )
    db.add(item)
    db.flush()
    return item


def test_step_finalize_idempotent(db, weekly_run_deadline_passed):
    weekly_run_deadline_passed.finalized_at = datetime.now(timezone.utc)
    db.flush()
    original_ts = weekly_run_deadline_passed.finalized_at
    from app.services.orchestrator.steps import step_finalize
    step_finalize(weekly_run_deadline_passed, db)
    assert weekly_run_deadline_passed.finalized_at == original_ts


def test_step_finalize_sends_shopping_list(db, weekly_run_deadline_passed, meal_plan, approved_item, member, monkeypatch):
    sent = []

    class FakeBackend:
        def send(self, **kwargs):
            sent.append(kwargs)

    monkeypatch.setattr(
        "app.services.orchestrator.steps.get_email_backend",
        lambda: FakeBackend(),
    )
    from app.services.orchestrator.steps import step_finalize
    step_finalize(weekly_run_deadline_passed, db)
    assert weekly_run_deadline_passed.finalized_at is not None
    assert weekly_run_deadline_passed.status == "completed"
    assert len(sent) == 1
    assert "Shopping list" in sent[0]["subject"]
    assert "chicken" in sent[0]["html"].lower()


def test_step_finalize_no_approved_sends_empty_message(db, weekly_run_deadline_passed, meal_plan, pending_item, member, monkeypatch):
    sent = []

    class FakeBackend:
        def send(self, **kwargs):
            sent.append(kwargs)

    monkeypatch.setattr(
        "app.services.orchestrator.steps.get_email_backend",
        lambda: FakeBackend(),
    )
    from app.services.orchestrator.steps import step_finalize
    step_finalize(weekly_run_deadline_passed, db)
    assert weekly_run_deadline_passed.finalized_at is not None
    assert "No meals were approved" in sent[0]["html"]


# ---------------------------------------------------------------------------
# runner tests
# ---------------------------------------------------------------------------

def test_run_step_upserts_weekly_run(db, family, monkeypatch):
    monkeypatch.setattr(
        "app.services.orchestrator.steps.ScraperService.run_scrape",
        lambda self, **kw: {"status": "success", "items_scraped": 0},
    )

    class _NoCloseSession:
        """Proxy that delegates all attribute access to the test session
        but turns close() into a no-op so the transactional fixture stays live."""
        def __getattr__(self, name):
            return getattr(db, name)
        def close(self):
            pass

    monkeypatch.setattr(
        "app.services.orchestrator.runner.SessionLocal",
        lambda: _NoCloseSession(),
    )
    from app.services.orchestrator.runner import run_step
    run_step("scrape", WEEK)
    from app.models import WeeklyRun
    run = db.query(WeeklyRun).filter(WeeklyRun.family_id == family.id).first()
    assert run is not None
    assert run.scraped_at is not None


def test_run_step_invalid_raises(monkeypatch):
    from app.services.orchestrator.runner import run_step
    with pytest.raises(ValueError, match="Unknown step"):
        run_step("bogus", WEEK)


def test_run_week_calls_all_steps(monkeypatch):
    called = []

    def fake_run_step(step, week):
        called.append(step)

    monkeypatch.setattr(
        "app.services.orchestrator.runner.run_step",
        fake_run_step,
    )
    from app.services.orchestrator.runner import run_week
    run_week(WEEK)
    assert called == ["scrape", "generate", "email", "reminder", "deadline", "finalize"]


# ---------------------------------------------------------------------------
# Admin endpoint tests
# ---------------------------------------------------------------------------

@pytest.fixture()
def admin_client(db):
    from fastapi.testclient import TestClient
    from app.main import app
    from app.database import get_db

    app.dependency_overrides[get_db] = lambda: db
    client = TestClient(app)
    yield client
    app.dependency_overrides.clear()


ADMIN_HEADERS = {"Authorization": "Bearer test-admin-token"}


def test_orchestrate_status_returns_list(admin_client, family, monkeypatch):
    from app.config import settings
    monkeypatch.setattr(settings, "ADMIN_TOKEN", "test-admin-token")
    resp = admin_client.get("/api/admin/orchestrate/status", headers=ADMIN_HEADERS)
    assert resp.status_code == 200
    assert "runs" in resp.json()


def test_orchestrate_step_invalid_returns_400(admin_client, monkeypatch):
    from app.config import settings
    monkeypatch.setattr(settings, "ADMIN_TOKEN", "test-admin-token")
    resp = admin_client.post(
        "/api/admin/orchestrate/bogus", headers=ADMIN_HEADERS
    )
    assert resp.status_code == 400


def test_orchestrate_run_week_returns_202(admin_client, monkeypatch):
    from app.config import settings
    monkeypatch.setattr(settings, "ADMIN_TOKEN", "test-admin-token")
    monkeypatch.setattr(
        "app.api.admin.run_week",
        lambda week=None: None,
    )
    resp = admin_client.post(
        "/api/admin/orchestrate/run-week", headers=ADMIN_HEADERS
    )
    assert resp.status_code == 202
