"""The scheduler defers (never fails) a dispatch that hits a busy printer (#2598).

check_queue gates dispatch on _is_printer_idle(), but that treats FINISH as
idle and a printer can keep reporting FINISH for tens of seconds after it
accepted a project_file; a watchdog revert (#2555) also releases the dispatch
hold. So a re-selected item can reach _start_print while its printer has
actually started printing. Two guards keep that from cancelling the live job:

* pre-dispatch — before the FTP upload, a busy printer defers (item stays
  pending), so there is no wasted upload and no start command;
* post-dispatch — if the printer goes busy in the upload window and
  start_print() returns False, the item is reverted to pending (deferred), not
  marked failed.
"""

from contextlib import ExitStack
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

import backend.app.models  # noqa: F401 - populate Base.metadata
import backend.app.services.print_scheduler as scheduler_module
from backend.app.core.database import Base
from backend.app.models.archive import PrintArchive
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.services.print_scheduler import PrintScheduler
from backend.tests._fixtures.background_tasks import discarding_spawn_patch


@pytest.fixture
async def dispatch_case(tmp_path):
    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    session_maker = async_sessionmaker(engine, expire_on_commit=False)

    base_dir = tmp_path / "case"
    base_dir.mkdir()
    archive_rel = Path("archives") / "job.3mf"
    archive_abs = base_dir / archive_rel
    archive_abs.parent.mkdir(parents=True, exist_ok=True)
    archive_abs.write_bytes(b"archive payload")

    async with session_maker() as db:
        printer = Printer(
            name="Printer",
            serial_number="SERIAL",
            ip_address="127.0.0.1",
            access_code="access-code",
            model="A1MINI",
        )
        db.add(printer)
        await db.flush()
        archive = PrintArchive(
            printer_id=printer.id,
            filename="job.3mf",
            file_path=str(archive_rel),
            file_size=archive_abs.stat().st_size,
            status="completed",
        )
        db.add(archive)
        await db.flush()
        item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending")
        db.add(item)
        await db.commit()
        ids = SimpleNamespace(printer_id=printer.id, archive_id=archive.id, item_id=item.id)

    try:
        yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, ids=ids)
    finally:
        await engine.dispose()


def _base_patches(scheduler, ctx, upload_mock, start_print_mock, get_status):
    return [
        patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
        patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
        patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
        patch("backend.app.services.print_scheduler.printer_manager.start_print", start_print_mock),
        patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
        patch(
            "backend.app.services.print_scheduler.get_ftp_retry_settings",
            AsyncMock(return_value=(False, 0, 0, 1.0)),
        ),
        patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
        patch("backend.app.services.print_scheduler.upload_file_async", upload_mock),
        patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
        discarding_spawn_patch(),
        patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
        patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
        patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
    ]


async def _final_item(ctx):
    async with ctx.session_maker() as db:
        return await db.get(PrintQueueItem, ctx.ids.item_id)


@pytest.mark.asyncio
async def test_pre_dispatch_busy_defers_without_upload(dispatch_case):
    """Printer already RUNNING when _start_print begins → defer, no upload/start."""
    scheduler = PrintScheduler()
    upload = AsyncMock(return_value=True)
    start_print = MagicMock(return_value=True)
    get_status = MagicMock(return_value=SimpleNamespace(state="RUNNING", subtask_id=None, gcode_file=None))

    async with dispatch_case.session_maker() as db:
        item = await db.get(PrintQueueItem, dispatch_case.ids.item_id)
        with ExitStack() as stack:
            for p in _base_patches(scheduler, dispatch_case, upload, start_print, get_status):
                stack.enter_context(p)
            await scheduler._start_print(db, item)

    upload.assert_not_awaited()
    start_print.assert_not_called()
    final = await _final_item(dispatch_case)
    assert final.status == "pending", "a busy printer must defer the item, not consume it"


@pytest.mark.asyncio
async def test_post_dispatch_busy_reverts_to_pending_not_failed(dispatch_case):
    """Printer goes busy in the upload window; start_print returns False → defer."""
    scheduler = PrintScheduler()
    upload = AsyncMock(return_value=True)

    holder = {"state": "IDLE"}

    class _Status:
        subtask_id = None
        gcode_file = None

        @property
        def state(self):
            return holder["state"]

    def _start_print(*args, **kwargs):
        # The printer became busy between the pre-dispatch check and the publish.
        holder["state"] = "RUNNING"
        return False  # start_print() refused: busy

    start_print = MagicMock(side_effect=_start_print)
    get_status = MagicMock(return_value=_Status())

    async with dispatch_case.session_maker() as db:
        item = await db.get(PrintQueueItem, dispatch_case.ids.item_id)
        with ExitStack() as stack:
            for p in _base_patches(scheduler, dispatch_case, upload, start_print, get_status):
                stack.enter_context(p)
            await scheduler._start_print(db, item)

    upload.assert_awaited_once()  # it proceeded past the (idle) pre-dispatch check
    start_print.assert_called_once()
    final = await _final_item(dispatch_case)
    assert final.status == "pending", "a busy-refused start must defer, not fail the item"
    assert final.started_at is None
