"""Typed, zero-network integration coverage for the Telegram Todo 4 boundary."""

from __future__ import annotations

import sys
from dataclasses import dataclass, field
from datetime import date, time
from pathlib import Path
from typing import final, override

import pytest


ROOT = Path(__file__).resolve().parents[2]
PROFILE_PACKAGE = ROOT / "dualcoach" / "profile"
for source_path in (ROOT, PROFILE_PACKAGE):
    source_text = str(source_path)
    if source_text not in sys.path:
        sys.path.insert(0, source_text)

from checkin_cli.customer_coaching import (
    AiProcessingConsent,
    CustomerRegistry,
    CustomerRuntime,
    CustomerSchedule,
    CustomerSpec,
    PlanWeek,
    TelegramAddress,
    TwelveWeekPlan,
)
from checkin_cli.wizard import WizardService
from gateway.config import PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.platforms.nutrition_coaching import (
    CallbackInput,
    CustomerTransition,
    IncomingAddress,
    NutritionCoachingCoordinator,
    ResolvedCustomer,
)
from gateway.platforms.physique_checkin import PhysiqueCheckinBridge, WizardReply
from gateway.platforms.physique_checkin_bindings import ProjectionPhase
from gateway.platforms.physique_checkin_config import PhysiqueCheckinConfig
from gateway.platforms.telegram import TelegramAdapter
from gateway.platforms.telegram_polling_receipts import (
    TelegramIngressReceiptStore,
    TelegramPollingReceiptGate,
)


@final
@dataclass(frozen=True, slots=True)
class _Actor:
    id: int


@final
@dataclass(frozen=True, slots=True)
class _Chat:
    id: int
    type: str = "supergroup"
    is_forum: bool = True


@final
@dataclass(slots=True)
class _TextMessage:
    text: str
    message_id: int
    from_user: _Actor
    chat: _Chat
    message_thread_id: int
    replies: int = 0

    async def reply_text(
        self,
        text: str,
        reply_markup: object | None = None,
    ) -> None:
        _ = (text, reply_markup)
        self.replies += 1


@final
@dataclass(frozen=True, slots=True)
class _TextUpdate:
    update_id: object
    message: _TextMessage

    @property
    def effective_message(self) -> _TextMessage:
        return self.message


@final
@dataclass(slots=True)
class _CallbackMessage:
    message_id: int
    chat: _Chat
    message_thread_id: int

    @property
    def chat_id(self) -> int:
        return self.chat.id


@final
@dataclass(slots=True)
class _CallbackQuery:
    data: str
    from_user: _Actor
    message: _CallbackMessage
    ack_fails: bool = False
    answers: int = 0
    edits: int = 0

    async def answer(self, text: str | None = None) -> None:
        _ = text
        self.answers += 1
        if self.ack_fails:
            raise RuntimeError("ack_unavailable")

    async def edit_message_text(
        self,
        text: str,
        reply_markup: object | None = None,
    ) -> None:
        _ = (text, reply_markup)
        self.edits += 1


@final
@dataclass(frozen=True, slots=True)
class _CallbackUpdate:
    update_id: object
    callback_query: _CallbackQuery


@final
@dataclass(frozen=True, slots=True)
class _BotUser:
    id: int = 0


@final
@dataclass(frozen=True, slots=True)
class _ParentChat:
    id: int = 0


@final
@dataclass(frozen=True, slots=True)
class _BotChat:
    is_direct_messages: bool = False
    parent_chat: _ParentChat | None = None


@final
@dataclass(frozen=True, slots=True)
class _BotMember:
    can_manage_direct_messages: bool = False


@final
class _NoNetworkBot:
    async def get_me(self) -> _BotUser:
        return _BotUser()

    async def get_chat(self, chat_id: int) -> _BotChat:
        _ = chat_id
        return _BotChat()

    async def get_chat_member(self, chat_id: int, user_id: int) -> _BotMember:
        _ = (chat_id, user_id)
        return _BotMember()


@final
@dataclass(frozen=True, slots=True)
class _IngressContext:
    bot: _NoNetworkBot = field(default_factory=_NoNetworkBot)


@final
@dataclass(frozen=True, slots=True)
class _SentPrompt:
    message_id: int


@final
class _Coordinator(NutritionCoachingCoordinator):
    """A typed, narrow customer coordinator over a real bridge."""

    _bridge: PhysiqueCheckinBridge
    _address: IncomingAddress
    _resolved: ResolvedCustomer
    text_calls: int
    callback_calls: int

    def __init__(
        self,
        bridge: PhysiqueCheckinBridge,
        address: IncomingAddress,
        customer: CustomerRuntime,
    ) -> None:
        customer.data_root.mkdir(parents=True, exist_ok=True)
        disabled_customer = CustomerRuntime(
            customer.spec.model_copy(update={"enabled": False}),
            customer.data_root,
        )
        super().__init__(
            customer.data_root.parent,
            CustomerRegistry(
                TelegramAddress(user_id="owner", chat_id="owner", topic_id="owner"),
                (disabled_customer,),
            ),
        )
        self._bridge = bridge
        self._address = address
        self._resolved = ResolvedCustomer(customer, bridge)
        self.text_calls = 0
        self.callback_calls = 0

    @override
    def resolve(self, address: IncomingAddress) -> ResolvedCustomer | None:
        return self._resolved if address == self._address else None

    @override
    def resolve_trainer(self, address: IncomingAddress) -> ResolvedCustomer | None:
        _ = address
        return None

    @override
    def trainer_private_active(
        self,
        address: IncomingAddress,
    ) -> ResolvedCustomer | None:
        _ = address
        return None

    @override
    def owns_space(self, chat_id: str, topic_id: str) -> bool:
        return (chat_id, topic_id) == (self._address.chat_id, self._address.topic_id)

    @override
    def handle_text(
        self,
        address: IncomingAddress,
        text: str,
    ) -> CustomerTransition:
        self.text_calls += 1
        reply = self._bridge.handle_text(
            text,
            owner_id=address.user_id,
            chat_id=address.chat_id,
            topic_id=address.topic_id,
        )
        if reply is None:
            reply = WizardReply(True, False, "no active check-in", None, None)
        return CustomerTransition(reply)

    @override
    def handle_callback(self, incoming: CallbackInput) -> CustomerTransition:
        self.callback_calls += 1
        reply = self._bridge.handle_callback(
            incoming.data,
            owner_id=incoming.address.user_id,
            chat_id=incoming.address.chat_id,
            topic_id=incoming.address.topic_id,
            message_id=incoming.message_id,
        )
        return CustomerTransition(reply)


@final
class _IntegrationAdapter(TelegramAdapter):
    _coordinator: NutritionCoachingCoordinator | None
    _sends: list[_SentPrompt]
    _send_outcomes: list[int | Exception]
    generic_events: int

    def __init__(self, coordinator: NutritionCoachingCoordinator | None) -> None:
        super().__init__(PlatformConfig(enabled=True, token="network-disabled", extra={}))
        self._coordinator = coordinator
        self._sends = []
        self._send_outcomes = []
        self.generic_events = 0

    @property
    def sends(self) -> tuple[_SentPrompt, ...]:
        return tuple(self._sends)

    def set_send_outcomes(self, outcomes: tuple[int | Exception, ...]) -> None:
        self._send_outcomes = list(outcomes)

    async def receive_text(self, update: _TextUpdate) -> None:
        await self._handle_text_message(update, _IngressContext())

    async def receive_callback(self, update: _CallbackUpdate) -> None:
        await self._handle_callback_query(update, _IngressContext())

    @override
    def _get_nutrition_coaching(self) -> NutritionCoachingCoordinator | None:
        return self._coordinator

    @override
    def _enqueue_text_event(self, event: MessageEvent) -> None:
        _ = event
        self.generic_events += 1

    @override
    async def _send_nutrition_topic(
        self,
        *,
        chat_id: object,
        topic_id: object,
        **kwargs: object,
    ) -> object:
        _ = (chat_id, topic_id)
        text = kwargs.get("text")
        if not isinstance(text, str) or not text:
            raise RuntimeError("fake transport requires prompt text")
        outcome = self._send_outcomes.pop(0) if self._send_outcomes else 200 + len(self._sends)
        if isinstance(outcome, Exception):
            raise outcome
        prompt = _SentPrompt(outcome)
        self._sends.append(prompt)
        return prompt


@final
class _StandaloneAdapter(TelegramAdapter):
    _bridge: PhysiqueCheckinBridge
    _physique_checkin_config: PhysiqueCheckinConfig | None

    def __init__(self, bridge: PhysiqueCheckinBridge) -> None:
        super().__init__(PlatformConfig(enabled=True, token="network-disabled", extra={}))
        self._physique_checkin_config = bridge.config
        self._bridge = bridge

    async def receive_callback(self, update: _CallbackUpdate) -> None:
        await self._handle_callback_query(update, _IngressContext())

    @override
    def _get_nutrition_coaching(self) -> NutritionCoachingCoordinator | None:
        return None

    @override
    def _get_physique_checkin(self) -> PhysiqueCheckinBridge | None:
        return self._bridge


ADDRESS = IncomingAddress("7", "-1007", "3")


def _customer(tmp_path: Path) -> CustomerRuntime:
    weeks = tuple(
        PlanWeek(
            week=week,
            calories_kcal=2_300,
            protein_g=150,
            carbohydrate_g=280,
            fat_g=65,
            meal_structure=("meal",),
        )
        for week in range(1, 13)
    )
    spec = CustomerSpec(
        customer_key="customer-1",
        display_name="Customer",
        enabled=True,
        telegram=TelegramAddress(user_id="7", chat_id="-1007", topic_id="3"),
        schedule=CustomerSchedule(daily_time=time(8), weekly_weekday=0, monthly_day=1),
        ai_processing_consent=AiProcessingConsent(
            granted=True,
            recorded_on=date(2026, 1, 1),
            notice_version="privacy-v1",
        ),
        plan=TwelveWeekPlan(
            starts_on=date(2026, 1, 1),
            focus="nutrition_90_training_10",
            weeks=weeks,
        ),
    )
    return CustomerRuntime(spec, tmp_path / "customer")


def _bridge(tmp_path: Path) -> tuple[PhysiqueCheckinBridge, str]:
    bridge = PhysiqueCheckinBridge(
        PhysiqueCheckinConfig("7", "-1007", "3", 3_600, False, False),
        service=WizardService.for_standalone(tmp_path / "wizard"),
        binding_path=tmp_path / "bindings.json",
    )
    launcher = bridge.open_launcher("nutrition_daily", message_id="101")
    if launcher.callback_data is None:
        raise AssertionError("launcher callback missing")
    opened = bridge.handle_callback(
        launcher.callback_data,
        owner_id="7",
        chat_id="-1007",
        topic_id="3",
        message_id="101",
    )
    if not opened.accepted:
        raise AssertionError("launcher rejected")
    return bridge, launcher.callback_data


def _fixture(tmp_path: Path) -> tuple[_IntegrationAdapter, _Coordinator, PhysiqueCheckinBridge, str]:
    bridge, launcher = _bridge(tmp_path)
    coordinator = _Coordinator(bridge, ADDRESS, _customer(tmp_path))
    return _IntegrationAdapter(coordinator), coordinator, bridge, launcher


def _text_update(update_id: object, text: str, *, message_id: int = 102) -> _TextUpdate:
    return _TextUpdate(
        update_id=update_id,
        message=_TextMessage(text, message_id, _Actor(7), _Chat(-1007), 3),
    )


def _callback_update(
    update_id: object,
    callback_data: str,
    *,
    actor_id: int = 7,
    topic_id: int = 3,
    message_id: int = 101,
    ack_fails: bool = False,
) -> _CallbackUpdate:
    query = _CallbackQuery(
        callback_data,
        _Actor(actor_id),
        _CallbackMessage(message_id, _Chat(-1007), topic_id),
        ack_fails=ack_fails,
    )
    return _CallbackUpdate(update_id, query)


def _active_cursor(bridge: PhysiqueCheckinBridge) -> tuple[str, int]:
    active = bridge.active_cursor_identity()
    if active is None:
        raise AssertionError("active cursor missing")
    return active[0].step, active[0].version


def _unknown_callback(bridge: PhysiqueCheckinBridge) -> str:
    prompt = bridge.active_prompt()
    if prompt is None:
        raise AssertionError("active prompt missing")
    for row in prompt.button_rows:
        for _label, callback_data in row:
            if callback_data.endswith(":u"):
                return callback_data
    raise AssertionError("unknown callback missing")


def test_characterization_nutrition_daily_copy_is_stable() -> None:
    assert TelegramAdapter.nutrition_daily_customer_text({"answers": {}}).startswith(
        "오늘 체크인 완료\n\n- 체중: 기록 없음"
    )


@pytest.mark.asyncio
async def test_characterization_pc1_callback_routes_and_acknowledges(tmp_path: Path) -> None:
    bridge, _launcher = _bridge(tmp_path)
    adapter = _StandaloneAdapter(bridge)
    update = _callback_update(
        1,
        "pc1:0123456789abcdef0123456789abcdef:launch:0:start",
    )

    await adapter.receive_callback(update)

    assert update.callback_query.answers == 1
    assert update.callback_query.edits == 0


@pytest.mark.asyncio
async def test_typed_text_and_callback_cross_the_durable_stepper(tmp_path: Path) -> None:
    adapter, coordinator, bridge, stale_launcher = _fixture(tmp_path)

    await adapter.receive_text(_text_update(501, "71.2"))

    assert _active_cursor(bridge) == ("calories", 1)
    assert len(adapter.sends) == 1
    assert coordinator.text_calls == 1
    assert adapter.generic_events == 0
    projection = bridge.binding_store.load_projections(0)[0]
    assert projection.ingress.update_id == 501
    assert projection.ingress.message_id == 102
    assert projection.ingress.actor_id == 7
    assert projection.ingress.chat_id == -1007
    assert projection.ingress.topic_id == 3

    update = _callback_update(
        502,
        _unknown_callback(bridge),
        message_id=adapter.sends[0].message_id,
        ack_fails=True,
    )
    await adapter.receive_callback(update)

    assert update.callback_query.answers == 1
    assert update.callback_query.edits == 0
    assert _active_cursor(bridge) == ("macros", 2)
    assert len(adapter.sends) == 2
    assert coordinator.callback_calls == 1
    assert bridge.binding_store.load_projections(0)[1].ingress.update_id == 502

    await adapter.receive_callback(_callback_update(503, stale_launcher))

    assert len(adapter.sends) == 2
    assert _active_cursor(bridge) == ("macros", 2)


@pytest.mark.asyncio
async def test_malformed_typed_text_is_reserved_without_legacy_or_generic_fallback(
    tmp_path: Path,
    caplog: pytest.LogCaptureFixture,
) -> None:
    adapter, coordinator, bridge, _launcher = _fixture(tmp_path)

    with caplog.at_level("INFO"):
        await adapter.receive_text(_text_update("not-an-int", "71.2"))

    assert _active_cursor(bridge) == ("bodyweight", 0)
    assert coordinator.text_calls == 0
    assert adapter.generic_events == 0
    assert adapter.sends == ()
    assert bridge.binding_store.load_projections(0) == ()
    assert "not-an-int" not in caplog.text


@pytest.mark.asyncio
async def test_malformed_typed_callback_is_reserved_without_legacy_or_generic_fallback(
    tmp_path: Path,
    caplog: pytest.LogCaptureFixture,
) -> None:
    adapter, coordinator, bridge, _launcher = _fixture(tmp_path)
    update = _callback_update("not-an-int", _unknown_callback(bridge))

    with caplog.at_level("INFO"):
        await adapter.receive_callback(update)

    assert update.callback_query.answers == 1
    assert update.callback_query.edits == 0
    assert _active_cursor(bridge) == ("bodyweight", 0)
    assert coordinator.callback_calls == 0
    assert adapter.generic_events == 0
    assert adapter.sends == ()
    assert bridge.binding_store.load_projections(0) == ()
    assert "not-an-int" not in caplog.text


@pytest.mark.asyncio
async def test_duplicate_update_cannot_advance_or_republish(tmp_path: Path) -> None:
    adapter, coordinator, bridge, _launcher = _fixture(tmp_path)
    update = _text_update(601, "71.2")

    await adapter.receive_text(update)
    await adapter.receive_text(update)

    assert _active_cursor(bridge) == ("calories", 1)
    assert len(adapter.sends) == 1
    assert coordinator.text_calls == 1


@pytest.mark.asyncio
async def test_uncertain_send_is_terminal_for_polling_and_never_automatically_replays(
    tmp_path: Path,
) -> None:
    adapter, coordinator, bridge, _launcher = _fixture(tmp_path)
    adapter.set_send_outcomes((RuntimeError("write_outcome_unknown"),))
    first = _text_update(701, "71.2")
    gate = TelegramPollingReceiptGate(
        TelegramIngressReceiptStore(tmp_path / "polling-receipts.json"),
        on_blocked=lambda _update_id, _reason: pytest.fail("polling blocked"),
    )
    gate.captured((first,))
    assert await gate.begin(first) is False

    await adapter.receive_text(first)
    gate.completed(first)

    assert _active_cursor(bridge) == ("calories", 1)
    assert len(adapter.sends) == 0
    assert coordinator.text_calls == 1
    assert bridge.binding_store.load_projections(0)[0].phase is ProjectionPhase.DELIVERY_UNCERTAIN
    assert await gate.permits_offset(702, running=True) is True

    await adapter.receive_text(_text_update(702, "2300"))

    assert _active_cursor(bridge) == ("calories", 1)
    assert len(adapter.sends) == 0
    assert coordinator.text_calls == 1
