package org.dromara.app.controller;

import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.json.JSONObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppScheduleBo;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.*;
import org.dromara.app.service.*;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.vo.SysOssUploadVo;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.dromara.system.service.ISysUserService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;

import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
@Tag("dev")
public class AppControllerTest {

    @Mock private IAppDeviceService appDeviceService;
    @Mock private IAppScheduleService appScheduleService;
    @Mock private IAppScheduleDetailService appScheduleDetailService;
    @Mock private IAppSchedulingDeviceService appSchedulingDeviceService;
    @Mock private ISysUserService userService;
    @Mock private IAppWateringLogService appWateringLogService;
    @Mock private IDeviceCommandService deviceCommandService;
    @Mock private ISysOssService ossService;
    @Mock private IAppVersionService appVersionService;
    @Captor private ArgumentCaptor<Map<String, Object>> schedulePayloadCaptor;

    @Test
    public void checkVersion_returnsUpdateAvailableWhenConfiguredVersionDiffers() {
        AppController controller = newController();
        AppVersionVo latestVersion = new AppVersionVo();
        latestVersion.setPlatform("android");
        latestVersion.setLatestVersion("1.0.1");
        latestVersion.setVersionCode(2);
        latestVersion.setForceUpdate("1");
        latestVersion.setDownloadUrl("https://example.com/app.apk");
        latestVersion.setReleaseNotes("修复已知问题");
        when(appVersionService.queryLatestByPlatform("android")).thenReturn(latestVersion);

        R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("android", "1.0.0"));

        assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
        assertThat(result.getData().getPlatform()).isEqualTo("android");
        assertThat(result.getData().getCurrentVersion()).isEqualTo("1.0.0");
        assertThat(result.getData().getLatestVersion()).isEqualTo("1.0.1");
        assertThat(result.getData().getUpdateAvailable()).isTrue();
        assertThat(result.getData().getForceUpdate()).isTrue();
        assertThat(result.getData().getDownloadUrl()).isEqualTo("https://example.com/app.apk");
        assertThat(result.getData().getReleaseNotes()).isEqualTo("修复已知问题");
    }

    @Test
    public void checkVersion_returnsNoUpdateWhenConfiguredVersionMatches() {
        AppController controller = newController();
        AppVersionVo latestVersion = new AppVersionVo();
        latestVersion.setPlatform("ios");
        latestVersion.setLatestVersion("2.0.0");
        latestVersion.setVersionCode(3);
        latestVersion.setForceUpdate("0");
        latestVersion.setDownloadUrl("https://example.com/app");
        latestVersion.setReleaseNotes("最新版本");
        when(appVersionService.queryLatestByPlatform("ios")).thenReturn(latestVersion);

        R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("ios", "2.0.0"));

        assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
        assertThat(result.getData().getPlatform()).isEqualTo("ios");
        assertThat(result.getData().getCurrentVersion()).isEqualTo("2.0.0");
        assertThat(result.getData().getLatestVersion()).isEqualTo("2.0.0");
        assertThat(result.getData().getUpdateAvailable()).isFalse();
        assertThat(result.getData().getForceUpdate()).isFalse();
    }

    @Test
    public void checkVersion_rejectsMissingCurrentVersion() {
        AppController controller = newController();

        assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("android", " ")))
            .isInstanceOf(ServiceException.class)
            .hasMessage("当前版本不能为空");
        verify(appVersionService, never()).queryLatestByPlatform(any());
    }

    @Test
    public void checkVersion_rejectsUnsupportedPlatform() {
        AppController controller = newController();

        assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("harmony", "1.0.0")))
            .isInstanceOf(ServiceException.class)
            .hasMessage("平台类型仅支持 android 或 ios");
        verify(appVersionService, never()).queryLatestByPlatform(any());
    }

    @Test
    public void checkVersion_rejectsMissingVersionConfig() {
        AppController controller = newController();
        when(appVersionService.queryLatestByPlatform("android")).thenReturn(null);

        assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("android", "1.0.0")))
            .isInstanceOf(ServiceException.class)
            .hasMessage("版本配置不存在");
    }

    @Test
    public void uploadImage_uploadsToOssAndReturnsBackendVisibleInfo() {
        AppController controller = newController();
        MockMultipartFile file = new MockMultipartFile(
            "file",
            "plant.png",
            "image/png",
            pngBytes()
        );
        SysOssVo oss = new SysOssVo();
        oss.setOssId(123L);
        oss.setOriginalName("plant.png");
        oss.setUrl("https://bucket.oss-cn-hangzhou.aliyuncs.com/plant.png");
        when(ossService.upload(file)).thenReturn(oss);

        R<SysOssUploadVo> result = callWithLogin(1L, () -> controller.uploadImage(file));

        assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
        assertThat(result.getData().getOssId()).isEqualTo("123");
        assertThat(result.getData().getFileName()).isEqualTo("plant.png");
        assertThat(result.getData().getUrl()).isEqualTo("https://bucket.oss-cn-hangzhou.aliyuncs.com/plant.png");
        verify(ossService).upload(file);
    }

    @Test
    public void uploadImage_acceptsAllSupportedBinaryImageFormats() {
        AppController controller = newController();
        SysOssVo oss = new SysOssVo();
        oss.setOssId(123L);
        oss.setOriginalName("plant-image");
        oss.setUrl("https://bucket.oss-cn-hangzhou.aliyuncs.com/plant-image");
        when(ossService.upload(any(MultipartFile.class))).thenReturn(oss);
        List<MockMultipartFile> files = List.of(
            new MockMultipartFile("file", "plant.jpg", "image/jpeg", jpegBytes()),
            new MockMultipartFile("file", "plant.gif", "image/gif", gifBytes()),
            new MockMultipartFile("file", "plant.webp", "image/webp", webpBytes()),
            new MockMultipartFile("file", "plant.bmp", "image/bmp", bmpBytes())
        );

        for (MockMultipartFile file : files) {
            R<SysOssUploadVo> result = callWithLogin(1L, () -> controller.uploadImage(file));
            assertThat(result.getCode()).as(file.getOriginalFilename()).isEqualTo(200);
        }

        verify(ossService, times(files.size())).upload(any(MultipartFile.class));
    }

    @Test
    public void uploadImage_rejectsNonImageFile() {
        AppController controller = newController();
        MockMultipartFile file = new MockMultipartFile(
            "file",
            "readme.txt",
            "text/plain",
            new byte[]{1, 2, 3}
        );

        assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
            .isInstanceOf(ServiceException.class)
            .hasMessage("只能上传图片文件");
        verify(ossService, never()).upload(any(org.springframework.web.multipart.MultipartFile.class));
    }

    @Test
    public void uploadImage_rejectsForgedImageContent() {
        AppController controller = newController();
        MockMultipartFile file = new MockMultipartFile(
            "file",
            "plant.png",
            "image/png",
            "not-an-image".getBytes(StandardCharsets.UTF_8)
        );

        assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
            .isInstanceOf(ServiceException.class)
            .hasMessage("图片文件内容不合法");
        verify(ossService, never()).upload(any(MultipartFile.class));
    }

    @Test
    public void uploadImage_rejectsFileLargerThanTwentyMegabytes() {
        AppController controller = newController();
        MultipartFile file = mock(MultipartFile.class);
        when(file.isEmpty()).thenReturn(false);
        when(file.getSize()).thenReturn(20L * 1024 * 1024 + 1);

        assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
            .isInstanceOf(ServiceException.class)
            .hasMessage("图片大小不能超过20MB");
        verify(ossService, never()).upload(any(MultipartFile.class));
    }

    @Test
    public void uploadImage_rejectsSvg() {
        AppController controller = newController();
        MockMultipartFile file = new MockMultipartFile(
            "file",
            "plant.svg",
            "image/svg+xml",
            "<svg/>".getBytes(StandardCharsets.UTF_8)
        );

        assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
            .isInstanceOf(ServiceException.class)
            .hasMessage("仅支持jpg、jpeg、png、gif、webp、bmp图片");
        verify(ossService, never()).upload(any(MultipartFile.class));
    }

    @Test
    public void getDeviceInfo_propagatesUnexpectedException() {
        AppController controller = newController();
        IllegalStateException failure = new IllegalStateException("database unavailable");
        when(appDeviceService.queryById("D01")).thenThrow(failure);

        assertThatThrownBy(() -> callWithLogin(1L, () -> controller.getDeviceInfo("D01")))
            .isSameAs(failure);
    }

    @Test
    public void switchDevice_usesCurrentTimeWithSecondsAsStartTime() throws Exception {
        AppController controller = newController();
        when(appDeviceService.switchDevice(eq("D01"), eq("1"), any(), eq(10))).thenReturn(true);

        waitUntilCurrentSecondIsSafelyNonZero();
        R<Void> result = callWithLogin(99L,
            () -> controller.switchDevice("{\"deviceNo\":\"D01\",\"workStatus\":\"1\",\"durationMin\":\"10\"}"));

        assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
        ArgumentCaptor<String> startTimeCaptor = ArgumentCaptor.forClass(String.class);
        verify(appDeviceService).switchDevice(eq("D01"), eq("1"), startTimeCaptor.capture(), eq(10));
        assertThat(startTimeCaptor.getValue()).matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}");
        assertThat(startTimeCaptor.getValue()).doesNotEndWith(":00");
    }

    @Test
    public void scheduleDeviceList_usesSingleBatchBindingLookup() {
        AppController controller = newController();
        AppDeviceVo first = ownedDevice("D01", 99L);
        AppDeviceVo second = ownedDevice("D02", 99L);
        when(appDeviceService.queryList(any(AppDeviceBo.class))).thenReturn(List.of(first, second));
        when(appSchedulingDeviceService.findBoundDeviceNos(List.of("D01", "D02")))
            .thenReturn(Set.of("D01"));

        R<Map<String, Object>> result = callWithLogin(99L, () -> controller.scheduleDeviceList(10L));

        assertThat(result.getData().get("deviceList")).isEqualTo(List.of(second));
        verify(appSchedulingDeviceService, never()).findByDeviceNo(anyString());
    }

    @Test
    public void getScheduleInfo_usesSingleBatchDeviceLookup() {
        AppController controller = newController();
        AppScheduleVo schedule = ownedSchedule(10L, 99L);
        AppSchedulingDeviceVo firstBinding = new AppSchedulingDeviceVo();
        firstBinding.setDeviceNo("D01");
        AppSchedulingDeviceVo secondBinding = new AppSchedulingDeviceVo();
        secondBinding.setDeviceNo("D02");
        AppDeviceVo firstDevice = ownedDevice("D01", 99L);
        AppDeviceVo secondDevice = ownedDevice("D02", 99L);
        List<AppDeviceVo> queriedDevices = List.of(secondDevice, firstDevice);
        when(appScheduleService.queryById(10L)).thenReturn(schedule);
        when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of());
        when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class)))
            .thenReturn(List.of(firstBinding, secondBinding));
        when(appDeviceService.queryByDeviceNos(List.of("D01", "D02"))).thenReturn(queriedDevices);

        R<AppScheduleVo> result = callWithLogin(99L, () -> controller.getScheduleInfo(10L));

        assertThat(result.getData().getDeviceNos())
            .extracting(AppDeviceVo::getDeviceNo)
            .containsExactly("D01", "D02");
        verify(appDeviceService, never()).queryById("D01");
        verify(appDeviceService, never()).queryById("D02");
    }

    @Test
    public void addScheduleDevice_dispatchesSchedulePayloadAfterBinding() {
        AppController controller = newController();
        Long userId = 99L;
        AppScheduleVo schedule = new AppScheduleVo();
        schedule.setId(10L);
        schedule.setUserId(userId);
        schedule.setName("Morning");
        schedule.setStatus("1");

        AppDeviceVo device = new AppDeviceVo();
        device.setDeviceNo("D01");
        device.setUserId(userId);

        AppScheduleDetailVo detail = new AppScheduleDetailVo();
        detail.setId(101L);
        detail.setWeekday(1);
        detail.setTimeData("[{\"startTime\":\"08:00\",\"durationMin\":15}]");
        detail.setTriggerType("0");
        detail.setStatus("1");

        when(appScheduleService.queryById(10L)).thenReturn(schedule);
        when(appDeviceService.queryById("D01")).thenReturn(device);
        lenient().when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of(detail));
        when(appSchedulingDeviceService.insertByBo(any(AppSchedulingDeviceBo.class))).thenReturn(true);

        R<Void> result = callWithLogin(userId,
            () -> controller.addScheduleDevice("{\"scheduleId\":10,\"deviceNos\":[\"D01\"]}"));

        assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);

        verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), schedulePayloadCaptor.capture());
        Map<String, Object> payload = schedulePayloadCaptor.getValue();
        assertThat(payload.get("deviceNo")).isEqualTo("D01");
        assertThat(payload).containsKey("details").doesNotContainKeys("cmd", "schedule");

        List<?> details = asList(payload.get("details"));
        assertThat(details).hasSize(1);
        Map<?, ?> detailPayload = asMap(details.get(0));
        assertThat(detailPayload.get("weekday")).isEqualTo(1);
        assertThat(detailPayload.get("triggerType")).isEqualTo("0");
        assertThat(detailPayload.get("status")).isEqualTo("1");
        assertThat(detailPayload.containsKey("id")).isFalse();
        assertThat(detailPayload.containsKey("zones")).isFalse();

        List<?> timeData = asList(detailPayload.get("timeData"));
        assertThat(timeData).hasSize(1);
        JSONObject timeSlot = (JSONObject) timeData.get(0);
        assertThat(timeSlot.getStr("startTime")).isEqualTo("08:00");
        assertThat(timeSlot.getInt("durationMin")).isEqualTo(15);
    }

    @Test
    public void addScheduleDevice_doesNotDispatchWhenBindingAlreadyExists() {
        AppController controller = newController();
        Long userId = 99L;
        AppScheduleVo schedule = ownedSchedule(10L, userId);
        AppDeviceVo device = ownedDevice("D01", userId);
        AppSchedulingDeviceVo existingBinding = new AppSchedulingDeviceVo();

        when(appScheduleService.queryById(10L)).thenReturn(schedule);
        when(appDeviceService.queryById("D01")).thenReturn(device);
        when(appSchedulingDeviceService.queryByScheduleIdAndDeviceNo(10L, "D01")).thenReturn(existingBinding);

        R<Void> result = callWithLogin(userId,
            () -> controller.addScheduleDevice("{\"scheduleId\":10,\"deviceNos\":[\"D01\"]}"));

        assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
        verify(appSchedulingDeviceService, never()).insertByBo(any(AppSchedulingDeviceBo.class));
        verify(deviceCommandService, never()).sendScheduleBindCommand(eq("D01"), any());
    }

    @Test
    public void removeSchedule_dispatchesUnbindOnlyAfterDeleteSucceeds() {
        AppController controller = newController();
        Long userId = 99L;
        AppScheduleVo schedule = ownedSchedule(10L, userId);
        AppSchedulingDeviceVo binding = new AppSchedulingDeviceVo();
        binding.setDeviceNo("D01");

        when(appScheduleService.queryById(10L)).thenReturn(schedule);
        when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))).thenReturn(List.of(binding));
        when(appScheduleService.deleteWithValidByIds(List.of(10L), true)).thenReturn(true);

        R<Void> result = callWithLogin(userId, () -> controller.removeSchedule(new Long[]{10L}));

        assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
        InOrder ordered = inOrder(appScheduleService, deviceCommandService);
        ordered.verify(appScheduleService).deleteWithValidByIds(List.of(10L), true);
        ordered.verify(deviceCommandService).sendScheduleUnbindCommand("D01", 10L);
    }

    @Test
    public void removeSchedule_doesNotDispatchUnbindWhenDeleteFails() {
        AppController controller = newController();
        Long userId = 99L;
        AppScheduleVo schedule = ownedSchedule(10L, userId);
        AppSchedulingDeviceVo binding = new AppSchedulingDeviceVo();
        binding.setDeviceNo("D01");

        when(appScheduleService.queryById(10L)).thenReturn(schedule);
        when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))).thenReturn(List.of(binding));
        when(appScheduleService.deleteWithValidByIds(List.of(10L), true)).thenReturn(false);

        R<Void> result = callWithLogin(userId, () -> controller.removeSchedule(new Long[]{10L}));

        assertThat(result.getCode()).as(result.getMsg()).isEqualTo(500);
        verify(deviceCommandService, never()).sendScheduleUnbindCommand("D01", 10L);
    }

    @Test
    public void editScheduleStatus_dispatchesCanceledCommandWhenScheduleClosed() {
        AppController controller = newController();
        Long userId = 99L;
        AppScheduleVo schedule = ownedSchedule(10L, userId);
        AppSchedulingDeviceVo binding = new AppSchedulingDeviceVo();
        binding.setDeviceNo("D01");
        AppScheduleBo bo = new AppScheduleBo();
        bo.setId(10L);
        bo.setStatus("0");

        when(appScheduleService.queryById(10L)).thenReturn(schedule);
        when(appScheduleService.updateStatusByBo(bo)).thenReturn(true);
        when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))).thenReturn(List.of(binding));

        R<Void> result = callWithLogin(userId, () -> controller.editScheduleStatus(bo));

        assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
        verify(deviceCommandService).sendScheduleCanceledCommand("D01", 10L);
        verify(deviceCommandService, never()).sendScheduleBindCommand(eq("D01"), any());
    }

    @Test
    public void editScheduleStatus_dispatchesSchedulePayloadWhenScheduleOpened() {
        AppController controller = newController();
        Long userId = 99L;
        AppScheduleVo schedule = ownedSchedule(10L, userId);
        AppSchedulingDeviceVo binding = new AppSchedulingDeviceVo();
        binding.setDeviceNo("D01");
        AppScheduleDetailVo detail = new AppScheduleDetailVo();
        detail.setWeekday(1);
        detail.setTimeData("[{\"startTime\":\"08:00\",\"durationMin\":15}]");
        detail.setTriggerType("0");
        detail.setStatus("1");
        AppScheduleBo bo = new AppScheduleBo();
        bo.setId(10L);
        bo.setStatus("1");

        when(appScheduleService.queryById(10L)).thenReturn(schedule);
        when(appScheduleService.updateStatusByBo(bo)).thenReturn(true);
        when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))).thenReturn(List.of(binding));
        when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of(detail));

        R<Void> result = callWithLogin(userId, () -> controller.editScheduleStatus(bo));

        assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
        verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), any());
        verify(deviceCommandService, never()).sendScheduleCanceledCommand("D01", 10L);
    }

    @Test
    public void editScheduleStatus_queriesScheduleDetailsOnceForMultipleDevices() {
        AppController controller = newController();
        AppScheduleBo bo = new AppScheduleBo();
        bo.setId(10L);
        bo.setStatus("1");
        AppSchedulingDeviceVo first = new AppSchedulingDeviceVo();
        first.setDeviceNo("D01");
        AppSchedulingDeviceVo second = new AppSchedulingDeviceVo();
        second.setDeviceNo("D02");

        when(appScheduleService.queryById(10L)).thenReturn(ownedSchedule(10L, 99L));
        when(appScheduleService.updateStatusByBo(bo)).thenReturn(true);
        when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class)))
            .thenReturn(List.of(first, second));
        when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of());

        callWithLogin(99L, () -> controller.editScheduleStatus(bo));

        verify(appScheduleDetailService, times(1)).queryByScheduleIdByStatus(10L);
        verify(deviceCommandService, times(2)).sendScheduleBindCommand(anyString(), any());
    }

    @Test
    public void latestWaterLog_returnsLatestRunningLogById() throws Exception {
        AppController controller = newController();
        Long userId = 99L;
        AppDeviceVo device = ownedDevice("D01", userId);
        AppWateringLogVo laterStartTime = new AppWateringLogVo();
        laterStartTime.setId(1L);
        laterStartTime.setDeviceNo("D01");
        laterStartTime.setStatus("1");
        laterStartTime.setStartTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2026-07-06 16:39:00"));
        laterStartTime.setEndTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2026-07-06 16:49:00"));
        AppWateringLogVo latest = new AppWateringLogVo();
        latest.setId(2L);
        latest.setDeviceNo("D01");
        latest.setStatus("1");
        latest.setStartTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2026-07-06 08:30:00"));
        latest.setEndTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2026-07-06 08:40:00"));

        when(appDeviceService.queryById("D01")).thenReturn(device);
        when(appWateringLogService.queryList(any(AppWateringLogBo.class))).thenReturn(List.of(laterStartTime, latest));

        R<Map<String, Object>> result = callWithLogin(userId, () -> controller.latestWaterLog("D01"));

        assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
        assertThat(result.getData())
            .containsEntry("startTime", "2026-07-06 08:30:00")
            .containsEntry("endTime", "2026-07-06 08:40:00");
        ArgumentCaptor<AppWateringLogBo> captor = ArgumentCaptor.forClass(AppWateringLogBo.class);
        verify(appWateringLogService).queryList(captor.capture());
        assertThat(captor.getValue().getUserId()).isEqualTo(userId);
        assertThat(captor.getValue().getDeviceNo()).isEqualTo("D01");
        assertThat(captor.getValue().getStatus()).isEqualTo("1");
    }

    private AppController newController() {
        return new AppController(
            appDeviceService,
            appScheduleService,
            appScheduleDetailService,
            appSchedulingDeviceService,
            userService,
            appWateringLogService,
            deviceCommandService,
            ossService,
            appVersionService
        );
    }

    private <T> R<T> callWithLogin(Long userId, Supplier<R<T>> action) {
        try (GenericApplicationContext applicationContext = new GenericApplicationContext();
            MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
            Supplier<ObjectMapper> objectMapperSupplier = ObjectMapper::new;
            applicationContext.registerBean(ObjectMapper.class, objectMapperSupplier);
            applicationContext.refresh();
            new SpringUtil().setApplicationContext(applicationContext);
            loginHelper.when(LoginHelper::getUserId).thenReturn(userId);
            return action.get();
        }
    }

    private List<?> asList(Object value) {
        assertThat(value).isInstanceOf(List.class);
        return (List<?>) value;
    }

    private Map<?, ?> asMap(Object value) {
        assertThat(value).isInstanceOf(Map.class);
        return (Map<?, ?>) value;
    }

    private AppScheduleVo ownedSchedule(Long scheduleId, Long userId) {
        AppScheduleVo schedule = new AppScheduleVo();
        schedule.setId(scheduleId);
        schedule.setUserId(userId);
        return schedule;
    }

    private AppDeviceVo ownedDevice(String deviceNo, Long userId) {
        AppDeviceVo device = new AppDeviceVo();
        device.setDeviceNo(deviceNo);
        device.setUserId(userId);
        return device;
    }

    private byte[] pngBytes() {
        return new byte[]{
            (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
            0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52
        };
    }

    private byte[] jpegBytes() {
        return new byte[]{
            (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0,
            0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00
        };
    }

    private byte[] gifBytes() {
        return new byte[]{0x47, 0x49, 0x46, 0x38, 0x39, 0x61};
    }

    private byte[] webpBytes() {
        return new byte[]{
            0x52, 0x49, 0x46, 0x46, 0x18, 0x00, 0x00, 0x00,
            0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20
        };
    }

    private byte[] bmpBytes() {
        return new byte[]{0x42, 0x4D, 0x00, 0x00, 0x00, 0x00};
    }

    private void waitUntilCurrentSecondIsSafelyNonZero() throws InterruptedException {
        while (true) {
            int second = Calendar.getInstance().get(Calendar.SECOND);
            if (second > 1 && second < 59) {
                return;
            }
            Thread.sleep(50);
        }
    }
}
