"""Tests for the Moonraker / Klipper printer adapter.

Every public method of :class:`MoonrakerAdapter` is exercised with mocked
HTTP responses so the test suite runs without a real Moonraker instance.
"""

from __future__ import annotations

import json
from typing import Any
from unittest import mock

import pytest
import requests
from requests.exceptions import ConnectionError as ReqConnectionError
from requests.exceptions import Timeout

from kiln.printers.base import (
    JobProgress,
    PrinterCapabilities,
    PrinterError,
    PrinterFile,
    PrinterStatus,
    PrintResult,
    UploadResult,
)
from kiln.printers.moonraker import (
    MoonrakerAdapter,
    _map_moonraker_state,
    _safe_get,
)

# ---------------------------------------------------------------------------
# Fixtures & helpers
# ---------------------------------------------------------------------------

HOST = "http://klipper.local:7125"


def _adapter(**kwargs: Any) -> MoonrakerAdapter:
    """Create a :class:`MoonrakerAdapter` with sensible test defaults."""
    defaults: dict[str, Any] = {
        "host": HOST,
        "timeout": 5,
        "retries": 1,
    }
    defaults.update(kwargs)
    return MoonrakerAdapter(**defaults)


def _mock_response(
    status_code: int = 200,
    json_data: dict[str, Any] | None = None,
    text: str = "",
    ok: bool | None = None,
) -> mock.MagicMock:
    """Build a fake :class:`requests.Response`."""
    resp = mock.MagicMock(spec=requests.Response)
    resp.status_code = status_code
    resp.ok = ok if ok is not None else (200 <= status_code < 300)
    resp.text = text or json.dumps(json_data or {})
    if json_data is not None:
        resp.json.return_value = json_data
    else:
        resp.json.side_effect = ValueError("No JSON")
    return resp


# ---------------------------------------------------------------------------
# Constructor tests
# ---------------------------------------------------------------------------

class TestMoonrakerAdapterInit:
    """Tests for adapter construction and identity properties."""

    def test_empty_host_raises(self) -> None:
        with pytest.raises(ValueError, match="host must not be empty"):
            MoonrakerAdapter(host="")

    def test_trailing_slash_stripped(self) -> None:
        adapter = _adapter(host="http://klipper.local:7125/")
        assert adapter._host == "http://klipper.local:7125"

    def test_name_property(self) -> None:
        adapter = _adapter()
        assert adapter.name == "moonraker"

    def test_capabilities(self) -> None:
        caps = _adapter().capabilities
        assert isinstance(caps, PrinterCapabilities)
        assert caps.can_upload is True
        assert caps.can_set_temp is True
        assert caps.can_send_gcode is True
        assert caps.can_pause is True
        assert ".gcode" in caps.supported_extensions

    def test_repr(self) -> None:
        adapter = _adapter()
        assert "MoonrakerAdapter" in repr(adapter)
        assert HOST in repr(adapter)

    def test_no_api_key_by_default(self) -> None:
        adapter = _adapter()
        assert "X-Api-Key" not in adapter._session.headers

    def test_api_key_set_when_provided(self) -> None:
        adapter = _adapter(api_key="my-secret-key")
        assert adapter._session.headers["X-Api-Key"] == "my-secret-key"

    def test_empty_string_api_key_treated_as_none(self) -> None:
        adapter = _adapter(api_key="")
        assert "X-Api-Key" not in adapter._session.headers

    def test_retries_at_least_one(self) -> None:
        adapter = _adapter(retries=0)
        assert adapter._retries == 1


# ---------------------------------------------------------------------------
# _safe_get helper
# ---------------------------------------------------------------------------

class TestSafeGet:

    def test_simple_nested(self) -> None:
        data = {"a": {"b": {"c": 42}}}
        assert _safe_get(data, "a", "b", "c") == 42

    def test_missing_key(self) -> None:
        data = {"a": {"b": 1}}
        assert _safe_get(data, "a", "x") is None

    def test_custom_default(self) -> None:
        assert _safe_get({}, "missing", default="fallback") == "fallback"

    def test_non_dict_intermediate(self) -> None:
        data = {"a": "not-a-dict"}
        assert _safe_get(data, "a", "b") is None

    def test_empty_keys(self) -> None:
        data = {"x": 1}
        assert _safe_get(data) == data


# ---------------------------------------------------------------------------
# _map_moonraker_state helper
# ---------------------------------------------------------------------------

class TestMapMoonrakerState:

    @pytest.mark.parametrize(
        "state_string, expected",
        [
            ("ready", PrinterStatus.IDLE),
            ("printing", PrinterStatus.PRINTING),
            ("paused", PrinterStatus.PAUSED),
            ("error", PrinterStatus.ERROR),
            ("shutdown", PrinterStatus.OFFLINE),
            ("startup", PrinterStatus.BUSY),
            ("standby", PrinterStatus.IDLE),
            ("complete", PrinterStatus.IDLE),
            ("cancelled", PrinterStatus.IDLE),
        ],
    )
    def test_basic_states(self, state_string: str, expected: PrinterStatus) -> None:
        assert _map_moonraker_state(state_string) == expected

    def test_unknown_state(self) -> None:
        assert _map_moonraker_state("garbage") == PrinterStatus.UNKNOWN

    def test_ready_with_printing_print_state(self) -> None:
        assert _map_moonraker_state("ready", "printing") == PrinterStatus.PRINTING

    def test_ready_with_paused_print_state(self) -> None:
        assert _map_moonraker_state("ready", "paused") == PrinterStatus.PAUSED

    def test_ready_with_standby_print_state(self) -> None:
        assert _map_moonraker_state("ready", "standby") == PrinterStatus.IDLE

    def test_ready_with_unknown_print_state_falls_back(self) -> None:
        # If print_state is not in _STATE_MAP, fall back to klippy state.
        assert _map_moonraker_state("ready", "never-heard-of-this") == PrinterStatus.IDLE

    def test_non_ready_ignores_print_state(self) -> None:
        # If klippy is not "ready", we don't check print_state.
        assert _map_moonraker_state("error", "printing") == PrinterStatus.ERROR


# ---------------------------------------------------------------------------
# HTTP layer tests
# ---------------------------------------------------------------------------

class TestHTTPLayer:
    """Tests for _request, _get_json, _post retry / error handling."""

    def test_successful_get(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": "ok"})
        with mock.patch.object(adapter._session, "request", return_value=resp):
            result = adapter._get_json("/printer/info")
        assert result == {"result": "ok"}

    def test_non_retryable_http_error(self) -> None:
        adapter = _adapter()
        resp = _mock_response(status_code=404, text="Not Found", ok=False)
        with mock.patch.object(adapter._session, "request", return_value=resp):
            with pytest.raises(PrinterError, match="HTTP 404"):
                adapter._get_json("/printer/info")

    def test_retryable_http_error_exhausts_retries(self) -> None:
        adapter = _adapter(retries=2)
        resp = _mock_response(status_code=503, text="Unavailable", ok=False)
        with mock.patch.object(adapter._session, "request", return_value=resp):
            with mock.patch("kiln.printers.moonraker.time.sleep"):
                with pytest.raises(PrinterError, match="HTTP 503"):
                    adapter._get_json("/printer/info")

    def test_timeout_raises_printer_error(self) -> None:
        adapter = _adapter()
        with mock.patch.object(
            adapter._session, "request", side_effect=Timeout("timed out")
        ), pytest.raises(PrinterError, match="timed out"):
            adapter._get_json("/printer/info")

    def test_connection_error_raises_printer_error(self) -> None:
        adapter = _adapter()
        with mock.patch.object(
            adapter._session, "request", side_effect=ReqConnectionError("refused")
        ), pytest.raises(PrinterError, match="Could not connect"):
            adapter._get_json("/printer/info")

    def test_invalid_json_raises_printer_error(self) -> None:
        adapter = _adapter()
        resp = _mock_response(status_code=200)
        resp.json.side_effect = ValueError("bad json")
        with mock.patch.object(adapter._session, "request", return_value=resp):
            with pytest.raises(PrinterError, match="Invalid JSON"):
                adapter._get_json("/printer/info")

    def test_post_delegates_to_request(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"ok": True})
        with mock.patch.object(adapter._session, "request", return_value=resp) as mock_req:
            adapter._post("/printer/print/cancel")
        mock_req.assert_called_once()
        assert mock_req.call_args[0][0] == "POST"

    def test_retryable_succeeds_on_second_attempt(self) -> None:
        adapter = _adapter(retries=3)
        fail_resp = _mock_response(status_code=502, ok=False, text="Bad Gateway")
        ok_resp = _mock_response(json_data={"result": "ok"})
        with mock.patch.object(
            adapter._session, "request", side_effect=[fail_resp, ok_resp]
        ), mock.patch("kiln.printers.moonraker.time.sleep"):
            result = adapter._get_json("/printer/info")
        assert result == {"result": "ok"}

    def test_non_transient_request_exception_raises_immediately(self) -> None:
        adapter = _adapter(retries=3)
        with mock.patch.object(
            adapter._session,
            "request",
            side_effect=requests.exceptions.InvalidURL("bad url"),
        ), pytest.raises(PrinterError, match="Request error"):
            adapter._get_json("/printer/info")


# ---------------------------------------------------------------------------
# get_state tests
# ---------------------------------------------------------------------------

class TestGetState:

    def _printer_info_response(self, state: str = "ready") -> dict[str, Any]:
        return {"result": {"state": state, "state_message": ""}}

    def _objects_response(
        self,
        extruder_temp: float = 25.0,
        extruder_target: float = 0.0,
        bed_temp: float = 22.0,
        bed_target: float = 0.0,
        print_state: str = "standby",
    ) -> dict[str, Any]:
        return {
            "result": {
                "status": {
                    "extruder": {
                        "temperature": extruder_temp,
                        "target": extruder_target,
                    },
                    "heater_bed": {
                        "temperature": bed_temp,
                        "target": bed_target,
                    },
                    "print_stats": {
                        "state": print_state,
                        "filename": "",
                        "print_duration": 0,
                        "total_duration": 0,
                    },
                }
            }
        }

    def test_idle_state(self) -> None:
        adapter = _adapter()
        info_resp = _mock_response(json_data=self._printer_info_response("ready"))
        obj_resp = _mock_response(json_data=self._objects_response(print_state="standby"))

        with mock.patch.object(
            adapter._session, "request", side_effect=[info_resp, obj_resp]
        ):
            state = adapter.get_state()

        assert state.connected is True
        assert state.state == PrinterStatus.IDLE
        assert state.tool_temp_actual == 25.0
        assert state.tool_temp_target == 0.0
        assert state.bed_temp_actual == 22.0
        assert state.bed_temp_target == 0.0

    def test_printing_state(self) -> None:
        adapter = _adapter()
        info_resp = _mock_response(json_data=self._printer_info_response("ready"))
        obj_resp = _mock_response(
            json_data=self._objects_response(
                extruder_temp=205.0,
                extruder_target=210.0,
                bed_temp=60.0,
                bed_target=60.0,
                print_state="printing",
            )
        )

        with mock.patch.object(
            adapter._session, "request", side_effect=[info_resp, obj_resp]
        ):
            state = adapter.get_state()

        assert state.connected is True
        assert state.state == PrinterStatus.PRINTING
        assert state.tool_temp_actual == 205.0
        assert state.bed_temp_target == 60.0

    def test_paused_state(self) -> None:
        adapter = _adapter()
        info_resp = _mock_response(json_data=self._printer_info_response("ready"))
        obj_resp = _mock_response(json_data=self._objects_response(print_state="paused"))

        with mock.patch.object(
            adapter._session, "request", side_effect=[info_resp, obj_resp]
        ):
            state = adapter.get_state()

        assert state.state == PrinterStatus.PAUSED

    def test_connection_error_returns_offline(self) -> None:
        adapter = _adapter()
        with mock.patch.object(
            adapter._session,
            "request",
            side_effect=ReqConnectionError("refused"),
        ):
            state = adapter.get_state()

        assert state.connected is False
        assert state.state == PrinterStatus.OFFLINE

    def test_timeout_returns_offline(self) -> None:
        adapter = _adapter()
        with mock.patch.object(
            adapter._session,
            "request",
            side_effect=Timeout("timed out"),
        ):
            state = adapter.get_state()

        assert state.connected is False
        assert state.state == PrinterStatus.OFFLINE

    def test_klippy_not_ready_returns_early(self) -> None:
        adapter = _adapter()
        info_resp = _mock_response(json_data=self._printer_info_response("startup"))

        with mock.patch.object(
            adapter._session, "request", side_effect=[info_resp]
        ) as mock_req:
            state = adapter.get_state()

        # Only one request should have been made (no objects query).
        assert mock_req.call_count == 1
        assert state.connected is True
        assert state.state == PrinterStatus.BUSY

    def test_shutdown_state(self) -> None:
        adapter = _adapter()
        info_resp = _mock_response(json_data=self._printer_info_response("shutdown"))

        with mock.patch.object(adapter._session, "request", side_effect=[info_resp]):
            state = adapter.get_state()

        assert state.connected is True
        assert state.state == PrinterStatus.OFFLINE

    def test_error_state(self) -> None:
        adapter = _adapter()
        info_resp = _mock_response(json_data=self._printer_info_response("error"))

        with mock.patch.object(adapter._session, "request", side_effect=[info_resp]):
            state = adapter.get_state()

        assert state.connected is True
        assert state.state == PrinterStatus.ERROR

    def test_objects_query_failure_still_returns_state(self) -> None:
        adapter = _adapter()
        info_resp = _mock_response(json_data=self._printer_info_response("ready"))
        obj_resp = _mock_response(status_code=500, text="Internal Error", ok=False)

        with mock.patch.object(
            adapter._session, "request", side_effect=[info_resp, obj_resp]
        ):
            state = adapter.get_state()

        assert state.connected is True
        assert state.state == PrinterStatus.IDLE
        assert state.tool_temp_actual is None

    def test_non_connection_error_re_raises(self) -> None:
        adapter = _adapter()
        resp = _mock_response(status_code=401, text="Unauthorized", ok=False)
        with mock.patch.object(adapter._session, "request", return_value=resp):
            with pytest.raises(PrinterError, match="HTTP 401"):
                adapter.get_state()

    def test_non_string_klippy_state_handled(self) -> None:
        adapter = _adapter()
        info_resp = _mock_response(json_data={"result": {"state": 12345}})

        with mock.patch.object(adapter._session, "request", side_effect=[info_resp]):
            state = adapter.get_state()

        assert state.connected is True
        assert state.state == PrinterStatus.UNKNOWN


# ---------------------------------------------------------------------------
# get_job tests
# ---------------------------------------------------------------------------

class TestGetJob:

    def _job_response(
        self,
        filename: str = "test.gcode",
        progress: float = 0.5,
        print_duration: float = 600.0,
        total_duration: float = 620.0,
    ) -> dict[str, Any]:
        return {
            "result": {
                "status": {
                    "print_stats": {
                        "filename": filename,
                        "print_duration": print_duration,
                        "total_duration": total_duration,
                        "state": "printing",
                    },
                    "virtual_sdcard": {
                        "progress": progress,
                        "is_active": True,
                        "file_position": 1000,
                    },
                }
            }
        }

    def test_active_job(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data=self._job_response())

        with mock.patch.object(adapter._session, "request", return_value=resp):
            job = adapter.get_job()

        assert isinstance(job, JobProgress)
        assert job.file_name == "test.gcode"
        assert job.completion == 50.0
        assert job.print_time_seconds == 600
        # time_left = (600 / 0.5) - 600 = 600
        assert job.print_time_left_seconds == 600

    def test_no_active_job(self) -> None:
        adapter = _adapter()
        resp = _mock_response(
            json_data={
                "result": {
                    "status": {
                        "print_stats": {
                            "filename": "",
                            "print_duration": 0,
                            "total_duration": 0,
                            "state": "standby",
                        },
                        "virtual_sdcard": {
                            "progress": 0.0,
                            "is_active": False,
                        },
                    }
                }
            }
        )

        with mock.patch.object(adapter._session, "request", return_value=resp):
            job = adapter.get_job()

        assert job.file_name is None
        assert job.completion == 0.0

    def test_completed_job(self) -> None:
        adapter = _adapter()
        resp = _mock_response(
            json_data=self._job_response(progress=1.0, print_duration=3600.0)
        )

        with mock.patch.object(adapter._session, "request", return_value=resp):
            job = adapter.get_job()

        assert job.completion == 100.0
        assert job.print_time_seconds == 3600
        assert job.print_time_left_seconds == 0

    def test_progress_at_zero_no_time_left(self) -> None:
        adapter = _adapter()
        resp = _mock_response(
            json_data=self._job_response(progress=0.0, print_duration=0.0)
        )

        with mock.patch.object(adapter._session, "request", return_value=resp):
            job = adapter.get_job()

        assert job.completion == 0.0
        assert job.print_time_left_seconds is None

    def test_connection_error_raises(self) -> None:
        adapter = _adapter()
        with mock.patch.object(
            adapter._session,
            "request",
            side_effect=ReqConnectionError("down"),
        ), pytest.raises(PrinterError):
            adapter.get_job()


# ---------------------------------------------------------------------------
# list_files tests
# ---------------------------------------------------------------------------

class TestListFiles:

    def _files_response(self) -> dict[str, Any]:
        return {
            "result": [
                {
                    "path": "test_print.gcode",
                    "modified": 1700000000.0,
                    "size": 12345,
                    "permissions": "rw",
                },
                {
                    "path": "subdir/nested.gcode",
                    "modified": 1700001000.0,
                    "size": 67890,
                    "permissions": "rw",
                },
            ]
        }

    def test_lists_files(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data=self._files_response())

        with mock.patch.object(adapter._session, "request", return_value=resp):
            files = adapter.list_files()

        assert len(files) == 2
        assert all(isinstance(f, PrinterFile) for f in files)

        assert files[0].name == "test_print.gcode"
        assert files[0].path == "test_print.gcode"
        assert files[0].size_bytes == 12345
        assert files[0].date == 1700000000

        assert files[1].name == "nested.gcode"
        assert files[1].path == "subdir/nested.gcode"

    def test_empty_file_list(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": []})

        with mock.patch.object(adapter._session, "request", return_value=resp):
            files = adapter.list_files()

        assert files == []

    def test_non_list_result_handled(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": "not-a-list"})

        with mock.patch.object(adapter._session, "request", return_value=resp):
            files = adapter.list_files()

        assert files == []

    def test_non_dict_entries_skipped(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": ["not-a-dict", 123]})

        with mock.patch.object(adapter._session, "request", return_value=resp):
            files = adapter.list_files()

        assert files == []

    def test_file_without_modified_field(self) -> None:
        adapter = _adapter()
        resp = _mock_response(
            json_data={
                "result": [
                    {"path": "nodate.gcode", "size": 100},
                ]
            }
        )

        with mock.patch.object(adapter._session, "request", return_value=resp):
            files = adapter.list_files()

        assert len(files) == 1
        assert files[0].date is None

    def test_passes_root_gcodes_param(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": []})

        with mock.patch.object(adapter._session, "request", return_value=resp) as mock_req:
            adapter.list_files()

        call_kwargs = mock_req.call_args
        assert call_kwargs.kwargs.get("params") == {"root": "gcodes"}


# ---------------------------------------------------------------------------
# upload_file tests
# ---------------------------------------------------------------------------

class TestUploadFile:

    def test_successful_upload(self, tmp_path: Any) -> None:
        gcode_file = tmp_path / "test.gcode"
        gcode_file.write_text("G28\nG1 X10 Y10 Z10\n")

        adapter = _adapter()
        resp = _mock_response(
            json_data={
                "result": {
                    "item": {"path": "test.gcode", "root": "gcodes"},
                    "action": "create_file",
                }
            }
        )

        with mock.patch.object(adapter._session, "request", return_value=resp):
            result = adapter.upload_file(str(gcode_file))

        assert isinstance(result, UploadResult)
        assert result.success is True
        assert result.file_name == "test.gcode"
        assert "Uploaded" in result.message

    def test_file_not_found_raises(self) -> None:
        adapter = _adapter()
        with pytest.raises(FileNotFoundError, match="Local file not found"):
            adapter.upload_file("/nonexistent/path/file.gcode")

    def test_permission_error_raises_printer_error(self, tmp_path: Any) -> None:
        gcode_file = tmp_path / "locked.gcode"
        gcode_file.write_text("G28\n")

        adapter = _adapter()
        with mock.patch("builtins.open", side_effect=PermissionError("no read")):
            with pytest.raises(PrinterError, match="Permission denied"):
                adapter.upload_file(str(gcode_file))

    def test_upload_with_no_json_response(self, tmp_path: Any) -> None:
        gcode_file = tmp_path / "test.gcode"
        gcode_file.write_text("G28\n")

        adapter = _adapter()
        resp = _mock_response(status_code=200)
        resp.json.side_effect = ValueError("no json")

        with mock.patch.object(adapter._session, "request", return_value=resp):
            result = adapter.upload_file(str(gcode_file))

        assert result.success is True
        assert result.file_name == "test.gcode"


# ---------------------------------------------------------------------------
# start_print tests
# ---------------------------------------------------------------------------

class TestStartPrint:

    def test_start_print(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": {}})

        with mock.patch.object(adapter._session, "request", return_value=resp) as mock_req:
            result = adapter.start_print("test.gcode")

        assert isinstance(result, PrintResult)
        assert result.success is True
        assert "test.gcode" in result.message

        # Verify the filename was passed as a query param.
        call_kwargs = mock_req.call_args
        assert call_kwargs.kwargs.get("params") == {"filename": "test.gcode"}

    def test_start_print_failure(self) -> None:
        adapter = _adapter()
        resp = _mock_response(status_code=400, text="No file", ok=False)

        with mock.patch.object(adapter._session, "request", return_value=resp):
            with pytest.raises(PrinterError, match="HTTP 400"):
                adapter.start_print("missing.gcode")


# ---------------------------------------------------------------------------
# cancel_print tests
# ---------------------------------------------------------------------------

class TestCancelPrint:

    def test_cancel_print(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": {}})

        with mock.patch.object(adapter._session, "request", return_value=resp):
            result = adapter.cancel_print()

        assert result.success is True
        assert "cancelled" in result.message.lower()

    def test_cancel_when_not_printing(self) -> None:
        adapter = _adapter()
        resp = _mock_response(status_code=400, text="Not printing", ok=False)

        with mock.patch.object(adapter._session, "request", return_value=resp), pytest.raises(PrinterError):
            adapter.cancel_print()


# ---------------------------------------------------------------------------
# emergency_stop tests
# ---------------------------------------------------------------------------


class TestEmergencyStop:

    def test_emergency_stop(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": {}})

        with mock.patch.object(adapter._session, "request", return_value=resp) as m:
            result = adapter.emergency_stop()

        assert result.success is True
        assert "emergency" in result.message.lower()
        call_args = m.call_args
        assert "/printer/emergency_stop" in call_args[1].get("url", call_args[0][1] if len(call_args[0]) > 1 else "")

    def test_emergency_stop_connection_error(self) -> None:
        adapter = _adapter()
        resp = _mock_response(status_code=500, text="Error", ok=False)

        with mock.patch.object(adapter._session, "request", return_value=resp), pytest.raises(PrinterError):
            adapter.emergency_stop()


# ---------------------------------------------------------------------------
# pause_print tests
# ---------------------------------------------------------------------------

class TestPausePrint:

    def test_pause_print(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": {}})

        with mock.patch.object(adapter._session, "request", return_value=resp):
            result = adapter.pause_print()

        assert result.success is True
        assert "paused" in result.message.lower()


# ---------------------------------------------------------------------------
# resume_print tests
# ---------------------------------------------------------------------------

class TestResumePrint:

    def test_resume_print(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": {}})

        with mock.patch.object(adapter._session, "request", return_value=resp):
            result = adapter.resume_print()

        assert result.success is True
        assert "resumed" in result.message.lower()


# ---------------------------------------------------------------------------
# set_tool_temp tests
# ---------------------------------------------------------------------------

class TestSetToolTemp:

    def test_set_tool_temp(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": "ok"})

        with mock.patch.object(adapter._session, "request", return_value=resp) as mock_req:
            ok = adapter.set_tool_temp(210.0)

        assert ok is True
        # Verify M104 G-code was sent.
        call_kwargs = mock_req.call_args
        assert call_kwargs.kwargs.get("params") == {"script": "M104 S210"}

    def test_turn_off_tool(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": "ok"})

        with mock.patch.object(adapter._session, "request", return_value=resp) as mock_req:
            ok = adapter.set_tool_temp(0)

        assert ok is True
        call_kwargs = mock_req.call_args
        assert call_kwargs.kwargs.get("params") == {"script": "M104 S0"}

    def test_set_tool_temp_error(self) -> None:
        adapter = _adapter()
        with mock.patch.object(
            adapter._session,
            "request",
            side_effect=ReqConnectionError("down"),
        ), pytest.raises(PrinterError):
            adapter.set_tool_temp(200)


# ---------------------------------------------------------------------------
# set_bed_temp tests
# ---------------------------------------------------------------------------

class TestSetBedTemp:

    def test_set_bed_temp(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": "ok"})

        with mock.patch.object(adapter._session, "request", return_value=resp) as mock_req:
            ok = adapter.set_bed_temp(60.0)

        assert ok is True
        call_kwargs = mock_req.call_args
        assert call_kwargs.kwargs.get("params") == {"script": "M140 S60"}

    def test_turn_off_bed(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": "ok"})

        with mock.patch.object(adapter._session, "request", return_value=resp) as mock_req:
            ok = adapter.set_bed_temp(0)

        assert ok is True
        call_kwargs = mock_req.call_args
        assert call_kwargs.kwargs.get("params") == {"script": "M140 S0"}


# ---------------------------------------------------------------------------
# _send_gcode tests
# ---------------------------------------------------------------------------

class TestSendGcode:

    def test_single_command(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": "ok"})

        with mock.patch.object(adapter._session, "request", return_value=resp) as mock_req:
            adapter._send_gcode("G28")

        call_kwargs = mock_req.call_args
        assert call_kwargs.kwargs.get("params") == {"script": "G28"}

    def test_multi_line_script(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": "ok"})

        with mock.patch.object(adapter._session, "request", return_value=resp) as mock_req:
            adapter._send_gcode("G28\nG1 X10 Y10 Z10 F300")

        call_kwargs = mock_req.call_args
        assert call_kwargs.kwargs.get("params") == {"script": "G28\nG1 X10 Y10 Z10 F300"}


# ---------------------------------------------------------------------------
# send_gcode (public interface) tests
# ---------------------------------------------------------------------------

class TestSendGcodePublic:

    def test_single_command(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": "ok"})

        with mock.patch.object(adapter._session, "request", return_value=resp) as mock_req:
            ok = adapter.send_gcode(["G28"])

        assert ok is True
        call_kwargs = mock_req.call_args
        assert call_kwargs.kwargs.get("params") == {"script": "G28"}

    def test_multiple_commands_joined(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": "ok"})

        with mock.patch.object(adapter._session, "request", return_value=resp) as mock_req:
            ok = adapter.send_gcode(["G28", "G1 X10 Y10 F300", "M104 S200"])

        assert ok is True
        call_kwargs = mock_req.call_args
        assert call_kwargs.kwargs.get("params") == {"script": "G28\nG1 X10 Y10 F300\nM104 S200"}

    def test_error_raises(self) -> None:
        adapter = _adapter()
        with mock.patch.object(
            adapter._session,
            "request",
            side_effect=ReqConnectionError("down"),
        ), pytest.raises(PrinterError):
            adapter.send_gcode(["G28"])


# ---------------------------------------------------------------------------
# delete_file tests
# ---------------------------------------------------------------------------

class TestDeleteFile:

    def test_success(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": "ok"})

        with mock.patch.object(adapter._session, "request", return_value=resp) as mock_req:
            ok = adapter.delete_file("benchy.gcode")

        assert ok is True
        # Verify the DELETE request was made to the correct endpoint.
        args, kwargs = mock_req.call_args
        assert args[0] == "DELETE"
        assert "benchy.gcode" in args[1]

    def test_url_encodes_path(self) -> None:
        adapter = _adapter()
        resp = _mock_response(json_data={"result": "ok"})

        with mock.patch.object(adapter._session, "request", return_value=resp) as mock_req:
            adapter.delete_file("folder/my file.gcode")

        args, _kwargs = mock_req.call_args
        # Spaces and slashes should be encoded.
        assert "folder%2Fmy%20file.gcode" in args[1]

    def test_error_raises(self) -> None:
        adapter = _adapter()
        resp = _mock_response(status_code=404, json_data=None, text="Not Found")

        with mock.patch.object(adapter._session, "request", return_value=resp), pytest.raises(PrinterError):
            adapter.delete_file("nonexistent.gcode")


# ---------------------------------------------------------------------------
# PrinterState serialisation
# ---------------------------------------------------------------------------

class TestPrinterStateSerialization:

    def test_to_dict_roundtrip(self) -> None:
        adapter = _adapter()
        info_resp = _mock_response(
            json_data={"result": {"state": "ready", "state_message": ""}}
        )
        obj_resp = _mock_response(
            json_data={
                "result": {
                    "status": {
                        "extruder": {"temperature": 200.0, "target": 210.0},
                        "heater_bed": {"temperature": 58.5, "target": 60.0},
                        "print_stats": {"state": "printing"},
                    }
                }
            }
        )

        with mock.patch.object(
            adapter._session, "request", side_effect=[info_resp, obj_resp]
        ):
            state = adapter.get_state()

        d = state.to_dict()
        assert d["connected"] is True
        assert d["state"] == "printing"
        assert d["tool_temp_actual"] == 200.0
        assert d["bed_temp_target"] == 60.0


# ---------------------------------------------------------------------------
# Integration-style: verify the adapter is a valid PrinterAdapter
# ---------------------------------------------------------------------------

class TestAdapterInterface:
    """Verify that MoonrakerAdapter correctly implements the abstract interface."""

    def test_is_printer_adapter_subclass(self) -> None:
        from kiln.printers.base import PrinterAdapter

        assert issubclass(MoonrakerAdapter, PrinterAdapter)

    def test_instance_check(self) -> None:
        from kiln.printers.base import PrinterAdapter

        adapter = _adapter()
        assert isinstance(adapter, PrinterAdapter)

    def test_importable_from_package(self) -> None:
        from kiln.printers import MoonrakerAdapter as Imported

        assert Imported is MoonrakerAdapter
