"""End-to-end tests for the Telegram fiscal-receipt flow (US-009).

These tests stitch the public handlers from
``backend.modules.telegram.handlers.receipt_handlers`` together to exercise the
three real user journeys, with Telegram, OCR queue, oracle_pool and
write_receipt mocked at the module boundary:

1. PDF flow:    send → "OCR processing" message → preview → click Confirmă → success
2. Photo flow:  send → preview → click Anulează → cleanup message + temp file removed
3. Concurrent:  two distinct user_ids submit files in parallel and both reach the
                preview state in well under 30s (validates ``concurrent_updates=True``).

Run::

    python3 -m pytest tests/e2e/test_receipt_telegram_flow.py -v
"""
import asyncio
import sys
import time
import types
from datetime import date, datetime
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

# ---------------------------------------------------------------------------
# Stub out heavy/optional dependencies BEFORE importing receipt_handlers,
# mirroring the strategy used by tests/modules/telegram/test_receipt_handlers.py
# so this file runs in environments without telegram / sqlmodel installed.
# ---------------------------------------------------------------------------
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(_PROJECT_ROOT))


class _FakeInlineKeyboardButton:
    """Stand-in for telegram.InlineKeyboardButton — receipt_handlers calls it
    with positional text and ``callback_data=`` kwarg, then hands it to a
    keyboard. We just need a non-MagicMock object so MagicMock's spec
    detection doesn't choke on the surrounding list."""

    def __init__(self, text, callback_data=None, **_):
        self.text = text
        self.callback_data = callback_data


class _FakeInlineKeyboardMarkup:
    """Stand-in for telegram.InlineKeyboardMarkup. Stores the keyboard so
    tests can assert against ``call.kwargs['reply_markup'].keyboard``."""

    def __init__(self, keyboard):
        self.keyboard = keyboard


def _install_telegram_stubs() -> None:
    """Install minimal telegram stubs ONLY when the module isn't already
    registered. We deliberately do NOT mutate an existing stub installed by
    another test file — instead, we override the bound names on
    ``receipt_handlers`` itself once it's imported (see below). This keeps
    the global ``sys.modules['telegram']`` state stable across files and
    avoids order-dependent test coupling."""
    if "telegram" not in sys.modules:
        tg = types.ModuleType("telegram")
        tg.Update = MagicMock
        tg.InlineKeyboardButton = _FakeInlineKeyboardButton
        tg.InlineKeyboardMarkup = _FakeInlineKeyboardMarkup
        sys.modules["telegram"] = tg

    if "telegram.constants" not in sys.modules:
        constants = types.ModuleType("telegram.constants")

        class _ParseMode:
            MARKDOWN = "Markdown"

        constants.ParseMode = _ParseMode
        sys.modules["telegram.constants"] = constants

    if "telegram.error" not in sys.modules:
        err = types.ModuleType("telegram.error")

        class _TelegramError(Exception):
            pass

        err.TelegramError = _TelegramError
        sys.modules["telegram.error"] = err

    if "telegram.ext" not in sys.modules:
        ext = types.ModuleType("telegram.ext")

        class _ContextTypes:
            DEFAULT_TYPE = MagicMock

        ext.ContextTypes = _ContextTypes
        sys.modules["telegram.ext"] = ext


def _install_data_entry_stubs() -> None:
    """Stub out backend.modules.data_entry so we don't pull SQLModel et al."""
    if "backend.modules.data_entry" in sys.modules:
        return

    de = types.ModuleType("backend.modules.data_entry")
    de.__path__ = []

    de_services = types.ModuleType("backend.modules.data_entry.services")
    de_services.__path__ = []

    de_ocr = types.ModuleType("backend.modules.data_entry.services.ocr")
    de_ocr.__path__ = []

    de_queue = types.ModuleType("backend.modules.data_entry.services.ocr.queue_client")
    de_queue.submit_ocr_job = AsyncMock(return_value="job-stub")
    de_queue.wait_for_result = AsyncMock(
        return_value={"success": True, "result": {}}
    )

    de_writer = types.ModuleType(
        "backend.modules.data_entry.services.oracle_receipt_writer"
    )
    de_writer.write_receipt = MagicMock(return_value=(0, "OK"))

    sys.modules["backend.modules.data_entry"] = de
    sys.modules["backend.modules.data_entry.services"] = de_services
    sys.modules["backend.modules.data_entry.services.ocr"] = de_ocr
    sys.modules["backend.modules.data_entry.services.ocr.queue_client"] = de_queue
    sys.modules[
        "backend.modules.data_entry.services.oracle_receipt_writer"
    ] = de_writer


_install_telegram_stubs()
_install_data_entry_stubs()

import oracledb  # noqa: E402

from backend.modules.telegram.handlers import receipt_handlers as rh  # noqa: E402

# `from telegram import InlineKeyboardButton, InlineKeyboardMarkup` in
# receipt_handlers binds the names at import time, so even after we install
# fake classes on the stub module, those references inside ``rh`` may still
# point at the older MagicMock-based stub. Re-bind them explicitly so the
# real handler's keyboard construction works against our fakes.
rh.InlineKeyboardButton = _FakeInlineKeyboardButton  # type: ignore[attr-defined]
rh.InlineKeyboardMarkup = _FakeInlineKeyboardMarkup  # type: ignore[attr-defined]


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture(autouse=True)
def _reset_module_state():
    rh._pending_receipts.clear()
    rh._write_pool_registered.clear()
    yield
    rh._pending_receipts.clear()
    rh._write_pool_registered.clear()


def _make_doc_update(user_id: int, file_id: str = "tg-file-1", file_name: str = "bon.pdf"):
    """Update for a Telegram document message (PDF/JPG)."""
    update = MagicMock()
    update.effective_user.id = user_id
    update.message.reply_text = AsyncMock()

    processing_msg = MagicMock()
    processing_msg.edit_text = AsyncMock()
    update.message.reply_text.return_value = processing_msg

    doc = MagicMock()
    doc.file_id = file_id
    doc.file_name = file_name
    update.message.document = doc

    return update, processing_msg


def _make_photo_update(user_id: int, file_id: str = "tg-photo-large"):
    """Update for a Telegram photo message (uses largest = photo[-1])."""
    update = MagicMock()
    update.effective_user.id = user_id
    update.message.reply_text = AsyncMock()

    processing_msg = MagicMock()
    processing_msg.edit_text = AsyncMock()
    update.message.reply_text.return_value = processing_msg

    photo_small = MagicMock()
    photo_small.file_id = "tg-photo-small"
    photo_large = MagicMock()
    photo_large.file_id = file_id
    update.message.photo = [photo_small, photo_large]

    return update, processing_msg


def _make_callback_update(user_id: int, action: str):
    update = MagicMock()
    update.effective_user.id = user_id
    update.callback_query.data = f"receipt:{action}:{user_id}"
    update.callback_query.answer = AsyncMock()
    update.callback_query.edit_message_text = AsyncMock()
    return update


def _make_context_with_download():
    """Telegram context whose bot.get_file() simulates a file download.

    The returned tg_file's download_to_drive writes a tiny placeholder so the
    temp path on disk actually exists (lets us assert it gets unlinked later).
    """
    context = MagicMock()
    tg_file = MagicMock()

    async def _download_to_drive(target_path):
        Path(target_path).write_bytes(b"%PDF-fake-receipt-bytes")

    tg_file.download_to_drive = AsyncMock(side_effect=_download_to_drive)
    context.bot.get_file = AsyncMock(return_value=tg_file)
    return context, tg_file


def _sample_ocr_result():
    return {
        "success": True,
        "result": {
            "partner_name": "LIDL DISCOUNT S.R.L.",
            "cui": "22891860",
            "receipt_date": date(2026, 5, 8),
            "receipt_number": "BON-2026-001",
            "amount": 156.78,
            "tva_total": 25.04,
            "confidence": 0.92,
        },
    }


# ===========================================================================
# Scenario 1: PDF send → preview → confirm → Oracle save
# ===========================================================================

@pytest.mark.asyncio
async def test_e2e_pdf_send_preview_confirm_success(tmp_path, monkeypatch):
    """Full happy path for a PDF receipt: download → OCR → preview → confirm → write."""
    user_id = 4001
    captured_temp_path: list[Path] = []

    update, processing_msg = _make_doc_update(user_id, file_name="bon-lidl.pdf")
    context, _tg_file = _make_context_with_download()

    # Capture the temp path created by handle_document_message
    real_named_temp = rh.tempfile.NamedTemporaryFile

    def _named_tmp(*args, **kwargs):
        kwargs["dir"] = str(tmp_path)
        f = real_named_temp(*args, **kwargs)
        captured_temp_path.append(Path(f.name))
        return f

    monkeypatch.setattr(rh.tempfile, "NamedTemporaryFile", _named_tmp)

    with patch(
        "backend.modules.telegram.handlers.receipt_handlers.is_user_linked",
        new=AsyncMock(return_value=True),
    ), patch(
        "backend.modules.telegram.handlers.receipt_handlers.submit_ocr_job",
        new=AsyncMock(return_value="job-pdf-1"),
    ), patch(
        "backend.modules.telegram.handlers.receipt_handlers.wait_for_result",
        new=AsyncMock(return_value=_sample_ocr_result()),
    ):
        await rh.handle_document_message(update, context)

    # --- Assertions on the preview phase ----------------------------------
    # 1) "OCR processing" placeholder was sent.
    update.message.reply_text.assert_awaited_once()
    first_reply_text = update.message.reply_text.await_args.args[0]
    assert "Procesez bonul" in first_reply_text

    # 2) Preview replaced the placeholder with parsed data.
    processing_msg.edit_text.assert_awaited()
    preview_text = processing_msg.edit_text.await_args_list[-1].args[0]
    assert "LIDL DISCOUNT" in preview_text
    assert "22891860" in preview_text
    assert "08.05.2026" in preview_text
    assert "156.78" in preview_text
    assert "Confirmați" in preview_text

    # Inline keyboard with confirm/cancel was attached.
    edit_kwargs = processing_msg.edit_text.await_args_list[-1].kwargs
    assert "reply_markup" in edit_kwargs

    # Pending state stored against this user.
    assert user_id in rh._pending_receipts
    assert captured_temp_path, "tempfile was not created"
    assert captured_temp_path[0].exists()
    # Document was a .pdf so the temp file should preserve that suffix.
    assert captured_temp_path[0].suffix == ".pdf"
    assert captured_temp_path[0].name.startswith(rh.TEMP_FILE_PREFIX)

    # --- Confirm phase ----------------------------------------------------
    cb_update = _make_callback_update(user_id, "confirm")
    fake_user_row = {"oracle_server_id": "srv1"}
    fake_cfg = {"user": "TESTUSER", "password": "PASS", "dsn": "host:1521/ROA"}

    fake_session = MagicMock()
    fake_session.get_active_company.return_value = {"id": 110, "name": "MARIUSM AUTO"}
    fake_sm = MagicMock()
    fake_sm.get_or_create_session = AsyncMock(return_value=fake_session)

    with patch(
        "backend.modules.telegram.handlers.receipt_handlers.get_user",
        new=AsyncMock(return_value=fake_user_row),
    ), patch.object(
        rh, "_build_oracle_config", return_value=fake_cfg
    ), patch.object(
        rh, "_resolve_schema", new=AsyncMock(return_value="MARIUSM_AUTO")
    ), patch(
        "backend.modules.telegram.agent.session.get_session_manager",
        return_value=fake_sm,
    ), patch.object(
        rh,
        "_save_to_oracle",
        new=AsyncMock(return_value=(7777, "Document salvat în ACT_TEMP")),
    ):
        await rh.handle_receipt_callback(cb_update, MagicMock())

    edit_calls = cb_update.callback_query.edit_message_text.await_args_list
    assert len(edit_calls) >= 2

    # Last message is the success confirmation.
    success_text = edit_calls[-1].args[0]
    assert "salvat" in success_text.lower()
    assert "7777" in success_text

    # Pending cleared and temp file removed.
    assert user_id not in rh._pending_receipts
    assert not captured_temp_path[0].exists()


# ===========================================================================
# Scenario 2: Photo send → preview → cancel → temp file deleted
# ===========================================================================

@pytest.mark.asyncio
async def test_e2e_photo_send_preview_cancel_cleanup(tmp_path, monkeypatch):
    """Photo path: cancellation must clear pending state, post a 'Bon anulat'
    message and unlink the temp file."""
    user_id = 4002
    captured_temp_path: list[Path] = []

    update, processing_msg = _make_photo_update(user_id, file_id="big-photo")
    context, _tg_file = _make_context_with_download()

    real_named_temp = rh.tempfile.NamedTemporaryFile

    def _named_tmp(*args, **kwargs):
        kwargs["dir"] = str(tmp_path)
        f = real_named_temp(*args, **kwargs)
        captured_temp_path.append(Path(f.name))
        return f

    monkeypatch.setattr(rh.tempfile, "NamedTemporaryFile", _named_tmp)

    with patch(
        "backend.modules.telegram.handlers.receipt_handlers.is_user_linked",
        new=AsyncMock(return_value=True),
    ), patch(
        "backend.modules.telegram.handlers.receipt_handlers.submit_ocr_job",
        new=AsyncMock(return_value="job-photo-1"),
    ), patch(
        "backend.modules.telegram.handlers.receipt_handlers.wait_for_result",
        new=AsyncMock(return_value=_sample_ocr_result()),
    ):
        await rh.handle_photo_message(update, context)

    # Largest photo was used.
    context.bot.get_file.assert_awaited_once_with("big-photo")

    # Preview shown and pending stored.
    assert user_id in rh._pending_receipts
    assert captured_temp_path[0].exists()
    preview_text = processing_msg.edit_text.await_args_list[-1].args[0]
    assert "Preview bon fiscal" in preview_text

    # --- Cancel phase -----------------------------------------------------
    cb_update = _make_callback_update(user_id, "cancel")
    await rh.handle_receipt_callback(cb_update, MagicMock())

    # Cancel acknowledgement message.
    cancel_text = cb_update.callback_query.edit_message_text.await_args.args[0]
    assert "anulat" in cancel_text.lower()

    # Cleanup verified: pending entry gone, temp file removed.
    assert user_id not in rh._pending_receipts
    assert not captured_temp_path[0].exists()


# ===========================================================================
# Scenario 3: Two users submit concurrently → both reach preview < 30s
# ===========================================================================

@pytest.mark.asyncio
async def test_e2e_concurrent_two_users_get_previews(tmp_path, monkeypatch):
    """Validates that ``concurrent_updates=True`` style usage is safe: two
    users submitting in parallel both end up with their own pending entry,
    isolated by user_id, and the whole thing finishes well under 30s.
    """
    user_a = 5001
    user_b = 5002

    update_a, msg_a = _make_doc_update(user_a, file_id="tg-a", file_name="a.pdf")
    update_b, msg_b = _make_photo_update(user_b, file_id="tg-b-large")

    ctx_a, _ = _make_context_with_download()
    ctx_b, _ = _make_context_with_download()

    real_named_temp = rh.tempfile.NamedTemporaryFile

    def _named_tmp(*args, **kwargs):
        kwargs["dir"] = str(tmp_path)
        return real_named_temp(*args, **kwargs)

    monkeypatch.setattr(rh.tempfile, "NamedTemporaryFile", _named_tmp)

    # In-flight counter: increments on entry, decrements on exit. If the two
    # flows actually overlap on the event loop, max_in_flight reaches 2; under
    # serial execution it stays at 1 regardless of total elapsed time. This
    # is what *proves* concurrency — the wall-clock check below is just an
    # AC-driven sanity bound.
    state = {"in_flight": 0, "max_in_flight": 0, "submit_count": 0}

    async def _slow_submit(_path):
        state["in_flight"] += 1
        state["max_in_flight"] = max(state["max_in_flight"], state["in_flight"])
        state["submit_count"] += 1
        await asyncio.sleep(0.05)
        state["in_flight"] -= 1
        return f"job-{state['submit_count']}"

    async def _slow_wait(_job_id, timeout=120):
        await asyncio.sleep(0.05)
        return _sample_ocr_result()

    with patch(
        "backend.modules.telegram.handlers.receipt_handlers.is_user_linked",
        new=AsyncMock(return_value=True),
    ), patch(
        "backend.modules.telegram.handlers.receipt_handlers.submit_ocr_job",
        side_effect=_slow_submit,
    ), patch(
        "backend.modules.telegram.handlers.receipt_handlers.wait_for_result",
        side_effect=_slow_wait,
    ):
        start = time.monotonic()
        await asyncio.gather(
            rh.handle_document_message(update_a, ctx_a),
            rh.handle_photo_message(update_b, ctx_b),
        )
        elapsed = time.monotonic() - start

    # Both users got their own preview, no cross-talk.
    assert user_a in rh._pending_receipts
    assert user_b in rh._pending_receipts
    assert rh._pending_receipts[user_a]["temp_path"] != rh._pending_receipts[user_b]["temp_path"]

    msg_a.edit_text.assert_awaited()
    msg_b.edit_text.assert_awaited()
    assert "Preview bon fiscal" in msg_a.edit_text.await_args.args[0]
    assert "Preview bon fiscal" in msg_b.edit_text.await_args.args[0]

    # AC requires "preview în <30s"; with concurrent execution and 50ms stubs
    # this should finish in well under a second on any sane runner.
    assert elapsed < 30.0, f"concurrent flow took {elapsed:.2f}s (>30s budget)"

    # Direct proof of concurrency: at some moment both submit_ocr_job calls
    # were simultaneously inside the await, which can only happen when the
    # two handlers progress in parallel on the event loop.
    assert state["max_in_flight"] == 2, (
        f"flows did not overlap; max in-flight={state['max_in_flight']}"
    )
