"""Strict boundary tests for advisory LLM onboarding reconciliation."""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
import hashlib
import json
from pathlib import Path
from types import SimpleNamespace
from typing import Protocol
from unittest.mock import AsyncMock, Mock

import pytest
from telegram import ForceReply

from gateway.platforms.nutrition_onboarding_reconciliation import (
    NutritionOnboardingClarification,
    NutritionOnboardingReconciliation,
    NutritionOnboardingReconciler,
    ReconciliationError,
    ReconciliationUnavailable,
    parse_reconciliation_json,
    reconciliation_payload,
    render_authoritative_customer_summary,
    render_reconciliation_text,
)
from gateway.platforms.telegram_nutrition_onboarding_runtime import (
    TelegramNutritionOnboardingRuntime,
)
from gateway.platforms.telegram_nutrition_onboarding import (
    encode_callback_hash,
)
from gateway.platforms.telegram_customer_bootstrap import BootstrapState, Role


VALID: dict[str, object] = {
    "summary_ko": "입력한 신체 정보와 식사 제약을 확인했습니다.",
    "facts_ko": ["하루 네 끼를 희망합니다."],
    "ambiguities_ko": [],
    "contradictions_ko": [],
    "safety_observations_ko": [],
    "clarifications": [],
}


def _canonical_digest(value: object) -> str:
    return hashlib.sha256(
        json.dumps(
            value,
            ensure_ascii=False,
            sort_keys=True,
            separators=(",", ":"),
        ).encode("utf-8")
    ).hexdigest()


@dataclass(frozen=True)
class _RoleClaim:
    user_id: str
    chat_id: str
    topic_id: str


@dataclass(frozen=True)
class _OnboardingSession:
    state: BootstrapState
    customer_key: str
    session_id: str
    chat_id: str
    role_claim: Callable[[Role], _RoleClaim | None]
    sid_hash: str | None = None


class _SessionListingStore(Protocol):
    def list_sessions(self) -> tuple[_OnboardingSession, ...]: ...


@dataclass(frozen=True)
class _SameSessionStore:
    session: _OnboardingSession

    def list_sessions(self) -> tuple[_OnboardingSession, ...]:
        return (self.session,)


class _SessionLookupStore(Protocol):
    def get_by_sid_hash(self, sid_hash: str) -> _OnboardingSession | None: ...


@dataclass(frozen=True)
class _SidHashSessionStore:
    session: _OnboardingSession

    def get_by_sid_hash(self, sid_hash: str) -> _OnboardingSession | None:
        if sid_hash != self.session.sid_hash:
            return None
        return self.session


def _runtime_profile(tmp_path: Path) -> Path:
    profile_root = tmp_path / "profile"
    registry_path = profile_root / "customers" / "registry.json"
    registry_path.parent.mkdir(parents=True)
    weeks = [
        {
            "week": week,
            "calories_kcal": 2300,
            "protein_g": 150,
            "meal_structure": ["아침", "점심", "저녁"],
        }
        for week in range(1, 13)
    ]
    registry_path.write_text(
        json.dumps(
            {
                "version": 1,
                "owner": {
                    "user_id": "30",
                    "chat_id": "-10030",
                    "topic_id": "90",
                },
                "customers": [
                    {
                        "customer_key": "client_001",
                        "display_name": "고객 001",
                        "enabled": False,
                        "telegram": {
                            "user_id": "10",
                            "chat_id": "10",
                            "topic_id": "0",
                        },
                        "ai_processing_consent": {
                            "granted": True,
                            "recorded_on": "2026-07-01",
                            "notice_version": "privacy-v1",
                        },
                        "schedule": {
                            "daily_time": "08:00",
                            "weekly_weekday": 0,
                            "monthly_day": 1,
                        },
                        "plan": {
                            "starts_on": "2026-07-01",
                            "focus": "nutrition_90_training_10",
                            "weeks": weeks,
                        },
                    }
                ],
            }
        ),
        encoding="utf-8",
    )
    return profile_root


def _persisted_record(
    reconciliation: NutritionOnboardingReconciliation,
    answers: dict[str, object],
) -> dict[str, object]:
    clarifications = [
        {
            "field": item.field,
            "kind": item.kind,
            "question_ko": item.question_ko,
        }
        for item in reconciliation.clarifications
    ]
    return {
        "state": "clarifying" if clarifications else "resolved",
        "answers_digest": _canonical_digest(answers),
        "advisory": reconciliation_payload(reconciliation),
        "clarifications": clarifications,
        "current_index": 0,
        "digest": "e" * 64,
    }


def test_authoritative_summary_renders_canonical_customer_values() -> None:
    answers: dict[str, object] = {
        "date_of_birth": "1996-08-01",
        "equation_sex_basis": "male",
        "height_cm": "180",
        "weight_kg": "80",
        "activity_category": "moderate",
        "activity_rationale": "주 3회 근력 운동",
        "goal_type": "maintain",
        "target_weight_kg": None,
        "target_date": None,
        "allergies": {"status": "none", "items": []},
        "intolerances": {"status": "none", "items": []},
        "religious_ethical_exclusions": {
            "status": "none",
            "items": [],
        },
        "disliked_foods": {"status": "none", "items": []},
        "dietary_preferences": {"status": "none", "items": []},
        "conditions": {"status": "none", "items": []},
        "medications": {"status": "none", "items": []},
        "pregnancy_breastfeeding": False,
        "eating_disorder_risk": False,
        "cooking_access": "기본 조리 가능",
        "budget_band": "보통",
        "meal_count": 3,
        "schedule_constraints": "없음",
    }

    rendered = render_authoritative_customer_summary(answers)

    assert "- 목표: 유지" in rendered
    assert "- 활동: 보통 · 주 3회 근력 운동" in rendered
    assert "알레르기 없음" in rendered
    assert "불내증 없음" in rendered
    assert "종교·윤리 제한 없음" in rendered
    assert "비선호 식품 없음" in rendered
    assert "식품 선호 없음" in rendered
    assert "- 건강 관련 입력: 별도 검토 입력이 없습니다." in rendered
    assert "{" not in rendered
    assert "}" not in rendered
    assert "status" not in rendered


def test_authoritative_summary_renders_provided_items_as_customer_text() -> None:
    answers: dict[str, object] = {
        "goal_type": "loss",
        "activity_category": "very_active",
        "activity_rationale": "주 5회 운동",
        "allergies": {"status": "provided", "items": ["땅콩", "새우"]},
        "intolerances": {"status": "none", "items": []},
        "religious_ethical_exclusions": {
            "status": "provided",
            "items": ["돼지고기"],
        },
        "disliked_foods": {"status": "provided", "items": ["가지"]},
        "dietary_preferences": {
            "status": "provided",
            "items": ["고단백식"],
        },
        "conditions": {"status": "none", "items": []},
        "medications": {"status": "none", "items": []},
        "pregnancy_breastfeeding": False,
        "eating_disorder_risk": False,
    }

    rendered = render_authoritative_customer_summary(answers)

    assert "- 목표: 감량" in rendered
    assert "- 활동: 매우 활동적 · 주 5회 운동" in rendered
    assert "알레르기 땅콩, 새우" in rendered
    assert "종교·윤리 제한 돼지고기" in rendered
    assert "비선호 식품 가지" in rendered
    assert "식품 선호 고단백식" in rendered
    assert "{" not in rendered
    assert "status" not in rendered


@pytest.mark.parametrize(
    ("field", "value"),
    (
        ("conditions", {"status": "provided", "items": ["고혈압"]}),
        ("medications", {"status": "provided", "items": ["철분"]}),
        ("pregnancy_breastfeeding", True),
        ("pregnancy_breastfeeding", None),
        ("eating_disorder_risk", True),
        ("eating_disorder_risk", None),
    ),
)
def test_authoritative_summary_classifies_safety_review_inputs(
    field: str,
    value: object,
) -> None:
    answers: dict[str, object] = {
        "conditions": {"status": "none", "items": []},
        "medications": {"status": "none", "items": []},
        "pregnancy_breastfeeding": False,
        "eating_disorder_risk": False,
    }
    answers[field] = value

    rendered = render_authoritative_customer_summary(answers)

    assert "- 건강 관련 입력: 검토할 입력이 있습니다." in rendered


def test_parse_reconciliation_json_accepts_exact_advisory_schema() -> None:
    parsed = parse_reconciliation_json(json.dumps(VALID, ensure_ascii=False))

    assert parsed.summary_ko == VALID["summary_ko"]
    assert parsed.facts_ko == ("하루 네 끼를 희망합니다.",)
    assert parsed.requires_correction is False


def test_parse_reconciliation_json_accepts_field_keyed_clarification() -> None:
    payload = {
        **VALID,
        "clarifications": [
            {
                "field": "meal_count",
                "kind": "ambiguity",
                "question_ko": (
                    "간식은 제외하고 실제 하루 식사 횟수를 알려주세요."
                ),
            }
        ],
    }

    parsed = parse_reconciliation_json(
        json.dumps(payload, ensure_ascii=False)
    )

    assert parsed.requires_correction is True
    assert parsed.clarifications[0].field == "meal_count"
    assert parsed.clarifications[0].kind == "ambiguity"
    rendered = render_reconciliation_text(parsed)
    assert "입력 내용을 정확히 반영하려고" in rendered
    assert "간식은 제외하고" in rendered
    assert "아래 확인 버튼" not in rendered


def test_parse_reconciliation_json_normalizes_bounded_summary_lines() -> None:
    payload = dict(VALID)
    payload["summary_ko"] = [
        "입력한 신체 정보를 확인했습니다.",
        "하루 네 끼를 희망합니다.",
    ]

    parsed = parse_reconciliation_json(
        json.dumps(payload, ensure_ascii=False)
    )

    assert parsed.summary_ko == (
        "입력한 신체 정보를 확인했습니다.\n"
        "하루 네 끼를 희망합니다."
    )


@pytest.mark.parametrize(
    "mutation",
    (
        {"ready": True},
        {"activation": "approved"},
        {"summary_ko": ""},
        {"summary_ko": []},
        {"summary_ko": [1]},
        {"facts_ko": [""]},
        {
            "clarifications": [
                {
                    "field": "unknown",
                    "kind": "ambiguity",
                    "question_ko": "확인이 필요합니다.",
                }
            ]
        },
        {
            "clarifications": [
                {
                    "field": "meal_count",
                    "kind": "guess",
                    "question_ko": "확인이 필요합니다.",
                }
            ]
        },
        {
            "clarifications": [
                {
                    "field": "meal_count",
                    "kind": "ambiguity",
                    "question_ko": "첫 번째 질문입니다.",
                },
                {
                    "field": "meal_count",
                    "kind": "contradiction",
                    "question_ko": "두 번째 질문입니다.",
                },
            ]
        },
        {
            "ambiguities_ko": ["식사 횟수가 모호합니다."],
            "clarifications": [],
        },
    ),
)
def test_parse_reconciliation_json_rejects_unsafe_or_empty_output(
    mutation: dict[str, object],
) -> None:
    payload = dict(VALID)
    payload.update(mutation)

    with pytest.raises(ReconciliationError):
        parse_reconciliation_json(json.dumps(payload, ensure_ascii=False))


def test_parse_reconciliation_json_rejects_markdown_wrapping() -> None:
    raw = f"```json\n{json.dumps(VALID, ensure_ascii=False)}\n```"

    with pytest.raises(ReconciliationError):
        parse_reconciliation_json(raw)


@pytest.mark.asyncio
async def test_reconciler_uses_configured_model_and_strict_response() -> None:
    create = Mock(
        return_value=SimpleNamespace(
            choices=[
                SimpleNamespace(
                    message=SimpleNamespace(
                        content=json.dumps(VALID, ensure_ascii=False)
                    )
                )
            ]
        )
    )
    client = SimpleNamespace(
        chat=SimpleNamespace(completions=SimpleNamespace(create=create))
    )
    reconciler = NutritionOnboardingReconciler(
        lambda: (client, "configured-model")
    )

    result = await reconciler.reconcile(
        {"meal_count": 4},
        consent_granted=True,
    )

    assert result.summary_ko == VALID["summary_ko"]
    assert create.call_args.kwargs["model"] == "configured-model"
    assert create.call_args.kwargs["temperature"] == 0


@pytest.mark.asyncio
async def test_reconciler_fails_closed_without_consent_or_client() -> None:
    factory = Mock(return_value=(None, None))
    reconciler = NutritionOnboardingReconciler(factory)

    with pytest.raises(ReconciliationUnavailable, match="consent"):
        await reconciler.reconcile({}, consent_granted=False)
    factory.assert_not_called()

    with pytest.raises(ReconciliationUnavailable, match="configured"):
        await reconciler.reconcile({}, consent_granted=True)


@pytest.mark.asyncio
async def test_production_attestation_card_includes_advisory_summary() -> None:
    reconciliation = NutritionOnboardingReconciliation(
        summary_ko="하루 네 끼와 체중 감량 목표를 확인했습니다.",
        facts_ko=("알레르기는 없습니다.",),
        ambiguities_ko=(),
        contradictions_ko=(),
        safety_observations_ko=(),
    )
    runtime = object.__new__(TelegramNutritionOnboardingRuntime)
    runtime.reconciler = SimpleNamespace(
        reconcile=AsyncMock(return_value=reconciliation)
    )
    runtime.adapter = SimpleNamespace(
        _send_nutrition_topic=AsyncMock(
            return_value=SimpleNamespace(message_id=901)
        )
    )
    authority = SimpleNamespace(consent_granted=True)
    runtime._current_authority = Mock(return_value=authority)
    runtime._route = Mock(return_value=("-100", "20"))
    store = SimpleNamespace(
        load_session=Mock(side_effect=ValueError),
        mark_prepared=Mock(
            return_value=SimpleNamespace(state="PREPARED")
        ),
        mark_uncertain=Mock(),
        mark_committed=Mock(),
    )
    answers: dict[str, object] = {"meal_count": 4}
    service = SimpleNamespace(
        store=store,
        reconciliation_answers=Mock(return_value=answers),
        reconciliation_record=Mock(return_value=None),
        record_reconciliation=Mock(
            return_value=_persisted_record(reconciliation, answers)
        ),
    )
    session = SimpleNamespace(session_id="session-1", sid_hash="a" * 64)
    status = SimpleNamespace(
        state=SimpleNamespace(value="customer_attestation"),
        answer_count=22,
    )

    await runtime._publish(session, service, status)

    service.reconciliation_answers.assert_called_once_with(
        authority=authority
    )
    sent = runtime.adapter._send_nutrition_topic.await_args.kwargs
    assert reconciliation.summary_ko in sent["text"]
    assert "알레르기는 없습니다." in sent["text"]
    assert "입력 내용을 정리했어요" in sent["text"]
    button = sent["reply_markup"].inline_keyboard[0][0]
    assert button.text == "입력 내용이 맞습니다"
    assert "운영자 검토" in sent["text"]
    assert store.mark_committed.call_count == 1


@pytest.mark.asyncio
async def test_resolved_clarification_discards_stale_advisory_sections() -> None:
    runtime = object.__new__(TelegramNutritionOnboardingRuntime)
    runtime.reconciler = SimpleNamespace(reconcile=AsyncMock())
    runtime.adapter = SimpleNamespace(
        _send_nutrition_topic=AsyncMock(
            return_value=SimpleNamespace(message_id=207)
        )
    )
    authority = SimpleNamespace(consent_granted=True)
    setattr(runtime, "_current_authority", Mock(return_value=authority))
    setattr(runtime, "_route", Mock(return_value=("-100", "20")))
    answers: dict[str, object] = {
        "activity_category": "moderate",
        "activity_rationale": "주 3회 근력 운동",
        "goal_type": "maintain",
        "allergies": {"status": "none", "items": []},
        "intolerances": {"status": "none", "items": []},
        "religious_ethical_exclusions": {
            "status": "none",
            "items": [],
        },
        "disliked_foods": {"status": "none", "items": []},
        "dietary_preferences": {"status": "none", "items": []},
        "conditions": {"status": "none", "items": []},
        "medications": {"status": "none", "items": []},
        "pregnancy_breastfeeding": False,
        "eating_disorder_risk": False,
        "meal_count": 3,
    }
    record: dict[str, object] = {
        "state": "resolved",
        "answers_digest": _canonical_digest(answers),
        "advisory": {
            "summary_ko": "수정 전 활동 요약입니다.",
            "facts_ko": ["수정 전 활동 사실입니다."],
            "ambiguities_ko": ["수정 전 모호함입니다."],
            "contradictions_ko": ["수정 전 모순입니다."],
            "safety_observations_ko": ["수정 전 안전 참고입니다."],
            "clarifications": [
                {
                    "field": "activity_rationale",
                    "kind": "ambiguity",
                    "question_ko": "수정 전 활동 확인 질문입니다.",
                }
            ],
        },
        "clarifications": [],
        "current_index": 1,
        "digest": "e" * 64,
    }
    store = SimpleNamespace(
        load_session=Mock(side_effect=ValueError),
        mark_prepared=Mock(
            return_value=SimpleNamespace(state="PREPARED")
        ),
        mark_uncertain=Mock(),
        mark_committed=Mock(),
    )
    service = SimpleNamespace(
        store=store,
        reconciliation_answers=Mock(return_value=answers),
        reconciliation_record=Mock(return_value=record),
    )
    session = SimpleNamespace(session_id="session-1", sid_hash="a" * 64)
    status = SimpleNamespace(
        state=SimpleNamespace(value="customer_attestation"),
        answer_count=22,
    )

    await getattr(runtime, "_publish")(session, service, status)

    runtime.reconciler.reconcile.assert_not_awaited()
    sent = runtime.adapter._send_nutrition_topic.await_args.kwargs["text"]
    assert "- 목표: 유지" in sent
    assert "- 활동: 보통 · 주 3회 근력 운동" in sent
    assert "- 건강 관련 입력: 별도 검토 입력이 없습니다." in sent
    assert "수정 전" not in sent
    assert "확인된 내용" not in sent
    assert "확인이 필요한 모호함" not in sent
    assert "서로 맞지 않는 내용" not in sent
    assert "안전 관련 참고사항" not in sent


@pytest.mark.asyncio
async def test_production_ambiguity_publishes_field_bound_force_reply() -> None:
    reconciliation = NutritionOnboardingReconciliation(
        summary_ko="식사 횟수를 확인해야 합니다.",
        facts_ko=(),
        ambiguities_ko=("식사 횟수가 모호합니다.",),
        contradictions_ko=(),
        safety_observations_ko=(),
        clarifications=(
            NutritionOnboardingClarification(
                field="meal_count",
                kind="ambiguity",
                question_ko=(
                    "간식은 제외하고 실제 하루 식사 횟수를 알려주세요."
                ),
            ),
        ),
    )
    runtime = object.__new__(TelegramNutritionOnboardingRuntime)
    runtime.reconciler = SimpleNamespace(
        reconcile=AsyncMock(return_value=reconciliation)
    )
    runtime.adapter = SimpleNamespace(
        _send_nutrition_topic=AsyncMock(
            return_value=SimpleNamespace(message_id=902)
        )
    )
    authority = SimpleNamespace(consent_granted=True)
    runtime._current_authority = Mock(return_value=authority)
    runtime._route = Mock(return_value=("-100", "20"))
    store = SimpleNamespace(
        load_session=Mock(side_effect=ValueError),
        mark_prepared=Mock(
            return_value=SimpleNamespace(state="PREPARED")
        ),
        mark_uncertain=Mock(),
        mark_committed=Mock(),
    )
    answers: dict[str, object] = {"meal_count": 4}
    service = SimpleNamespace(
        store=store,
        reconciliation_answers=Mock(return_value=answers),
        reconciliation_record=Mock(return_value=None),
        record_reconciliation=Mock(
            return_value=_persisted_record(reconciliation, answers)
        ),
    )
    session = SimpleNamespace(session_id="session-1", sid_hash="a" * 64)
    status = SimpleNamespace(
        state=SimpleNamespace(value="customer_attestation"),
        answer_count=22,
    )

    await runtime._publish(session, service, status)

    sent = runtime.adapter._send_nutrition_topic.await_args.kwargs
    assert "간식은 제외하고" in sent["text"]
    assert isinstance(sent["reply_markup"], ForceReply)
    prepared = store.mark_prepared.call_args.kwargs["payload"]
    assert prepared["clarification_field"] == "meal_count"
    assert prepared["answers_digest"]


@pytest.mark.asyncio
async def test_production_clarification_reply_revises_exact_field(
    tmp_path: Path,
) -> None:
    runtime = object.__new__(TelegramNutritionOnboardingRuntime)
    runtime.profile_root = _runtime_profile(tmp_path)
    claim = _RoleClaim(
        user_id="10",
        chat_id="10",
        topic_id="0",
    )
    session = _OnboardingSession(
        state=BootstrapState.AWAITING_ACTIVATION,
        customer_key="client_001",
        session_id="session-1",
        chat_id="10",
        role_claim=lambda role: claim if role is Role.CUSTOMER else None,
    )
    store: _SessionListingStore = _SameSessionStore(session)
    runtime.bootstrap = SimpleNamespace(store=store)
    authority = SimpleNamespace(consent_granted=True)
    runtime._current_authority = Mock(return_value=authority)
    evidence = SimpleNamespace(update_id=999)
    runtime._evidence = Mock(return_value=evidence)
    status = SimpleNamespace(
        state=SimpleNamespace(value="customer_attestation"),
        next_field=None,
    )
    revised = SimpleNamespace(
        state=SimpleNamespace(value="customer_attestation"),
        next_field=None,
    )
    publication = SimpleNamespace(
        message_id=901,
        payload={
            "clarification_field": "meal_count",
            "answers_digest": "digest-1",
        },
    )
    service = SimpleNamespace(
        status=Mock(return_value=status),
        store=SimpleNamespace(load_session=Mock(return_value=publication)),
        revise_reconciliation_answer=Mock(return_value=revised),
    )
    runtime._service = Mock(return_value=service)
    runtime._publish = AsyncMock()
    message = SimpleNamespace(
        text="4끼",
        message_id=902,
        message_thread_id=0,
        from_user=SimpleNamespace(id=10),
        chat=SimpleNamespace(id=10),
        reply_to_message=SimpleNamespace(message_id=901),
        reply_text=AsyncMock(),
    )

    assert await runtime.handle_text(
        SimpleNamespace(update_id=999),
        message,
    )

    service.revise_reconciliation_answer.assert_called_once_with(
        field="meal_count",
        value=4,
        expected_answers_digest="digest-1",
        authority=authority,
        evidence=evidence,
    )
    runtime._publish.assert_awaited_once_with(
        session,
        service,
        revised,
        reply_anchor_message_id=902,
    )


@pytest.mark.asyncio
async def test_production_reuses_persisted_next_clarification_after_restart() -> None:
    runtime = object.__new__(TelegramNutritionOnboardingRuntime)
    runtime.reconciler = SimpleNamespace(reconcile=AsyncMock())
    runtime.adapter = SimpleNamespace(
        _send_nutrition_topic=AsyncMock(
            return_value=SimpleNamespace(message_id=903)
        )
    )
    authority = SimpleNamespace(consent_granted=True)
    runtime._current_authority = Mock(return_value=authority)
    runtime._route = Mock(return_value=("-100", "20"))
    answers = {"meal_count": 4, "schedule_constraints": "미정"}
    answers_digest = _canonical_digest(answers)
    record = {
        "state": "clarifying",
        "answers_digest": answers_digest,
        "advisory": {
            "summary_ko": "입력 내용을 확인했습니다.",
            "facts_ko": [],
            "ambiguities_ko": ["두 항목을 확인해 주세요."],
            "contradictions_ko": [],
            "safety_observations_ko": [],
            "clarifications": [
                {
                    "field": "meal_count",
                    "kind": "ambiguity",
                    "question_ko": "하루 식사 횟수를 알려주세요.",
                },
                {
                    "field": "schedule_constraints",
                    "kind": "ambiguity",
                    "question_ko": "식사 준비가 가능한 시간을 알려주세요.",
                },
            ],
        },
        "clarifications": [
            {
                "field": "meal_count",
                "kind": "ambiguity",
                "question_ko": "하루 식사 횟수를 알려주세요.",
            },
            {
                "field": "schedule_constraints",
                "kind": "ambiguity",
                "question_ko": "식사 준비가 가능한 시간을 알려주세요.",
            },
        ],
        "current_index": 1,
        "digest": "e" * 64,
    }
    store = SimpleNamespace(
        load_session=Mock(side_effect=ValueError),
        mark_prepared=Mock(
            return_value=SimpleNamespace(state="PREPARED")
        ),
        mark_uncertain=Mock(),
        mark_committed=Mock(),
    )
    service = SimpleNamespace(
        store=store,
        reconciliation_answers=Mock(return_value=answers),
        reconciliation_record=Mock(return_value=record),
        record_reconciliation=Mock(),
    )
    session = SimpleNamespace(session_id="session-1", sid_hash="a" * 64)
    status = SimpleNamespace(
        state=SimpleNamespace(value="customer_attestation"),
        answer_count=22,
    )

    await runtime._publish(session, service, status)

    runtime.reconciler.reconcile.assert_not_awaited()
    service.record_reconciliation.assert_not_called()
    sent = runtime.adapter._send_nutrition_topic.await_args.kwargs
    assert "(2/2)" in sent["text"]
    assert "식사 준비가 가능한 시간을 알려주세요." in sent["text"]
    assert sent["reply_markup"] is not None
    prepared_payload = store.mark_prepared.call_args.kwargs["payload"]
    assert prepared_payload["clarification_field"] == "schedule_constraints"
    assert prepared_payload["answers_digest"] == answers_digest


@pytest.mark.asyncio
async def test_exact_attestation_callback_retires_button_and_advances(
    tmp_path: Path,
) -> None:
    runtime = object.__new__(TelegramNutritionOnboardingRuntime)
    runtime.profile_root = _runtime_profile(tmp_path)
    sid_hash = "a" * 64
    claim = _RoleClaim(
        user_id="10",
        chat_id="10",
        topic_id="0",
    )
    session = _OnboardingSession(
        state=BootstrapState.AWAITING_ACTIVATION,
        customer_key="client_001",
        session_id="session-1",
        sid_hash=sid_hash,
        chat_id="10",
        role_claim=lambda role: claim if role is Role.CUSTOMER else None,
    )
    store: _SessionLookupStore = _SidHashSessionStore(session)
    runtime.bootstrap = SimpleNamespace(store=store)
    publication = SimpleNamespace(generation=23, message_id=901)
    attestation = SimpleNamespace(
        state=SimpleNamespace(value="customer_attestation")
    )
    owner_review = SimpleNamespace(
        state=SimpleNamespace(value="owner_review")
    )
    service = SimpleNamespace(
        store=SimpleNamespace(load_session=Mock(return_value=publication)),
        status=Mock(return_value=attestation),
        attest_baseline=Mock(return_value=owner_review),
    )
    runtime._service = Mock(return_value=service)
    runtime._member_present = AsyncMock(return_value=True)
    authority = SimpleNamespace()
    runtime._current_authority = Mock(return_value=authority)
    evidence = SimpleNamespace(update_id=777)
    runtime._evidence = Mock(return_value=evidence)
    runtime.domain = SimpleNamespace(validate_route=Mock())
    runtime._publish = AsyncMock()
    runtime.adapter = SimpleNamespace(
        _send_nutrition_topic=AsyncMock(
            return_value=SimpleNamespace(message_id=902)
        )
    )
    query = SimpleNamespace(
        id="callback-777",
        from_user=SimpleNamespace(id=10),
        answer=AsyncMock(),
        edit_message_reply_markup=AsyncMock(),
    )
    message = SimpleNamespace(message_id=901)
    data = encode_callback_hash(
        action="attest",
        generation=23,
        sid_hash=sid_hash,
    )

    await runtime.handle_callback(query, data, message)

    runtime._evidence.assert_called_once()
    assert runtime._evidence.call_args.kwargs["update_id"] != 901
    runtime.domain.validate_route.assert_called_once_with(
        authority,
        evidence,
        role="customer",
    )
    service.attest_baseline.assert_called_once_with(
        authority=authority,
        evidence=evidence,
    )
    query.edit_message_reply_markup.assert_awaited_once_with(
        reply_markup=None
    )
    runtime._publish.assert_awaited_once_with(
        session,
        service,
        owner_review,
    )
    customer_notice = (
        runtime.adapter._send_nutrition_topic.await_args.kwargs["text"]
    )
    assert "입력 확인이 저장됐어요" in customer_notice
    assert "아직 영양 코칭은 시작" in customer_notice
