package org.dromara.mqtt;

import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.domain.mqtt.DeviceCommandAck;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.mqtt.config.properties.MqttProperties;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.redisson.api.RLock;
import org.redisson.api.RSet;
import org.redisson.api.RedissonClient;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;

import java.time.Duration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.TimeUnit;
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.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;

@Tag("dev")
class MqttCommandAckServiceTest {

    private static GenericApplicationContext applicationContext;

    @BeforeAll
    static void initializeRedisUtils() {
        if (TableInfoHelper.getTableInfo(AppDevice.class) == null) {
            TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), ""), AppDevice.class);
        }
        applicationContext = new GenericApplicationContext();
        applicationContext.registerBean(RedissonClient.class, () -> mock(RedissonClient.class));
        applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
        applicationContext.refresh();
        new SpringUtil().setApplicationContext(applicationContext);
    }

    @AfterAll
    static void closeApplicationContext() {
        applicationContext.close();
    }

    @Test
    void resolveMissingCommandId_usesSinglePendingCommandForDevice() {
        DeviceCommandAck ack = new DeviceCommandAck();
        ack.setCommandId("");

        DeviceCommand pending = new DeviceCommand();
        pending.setCommandId("cmd-1");
        pending.setDeviceNo("D01");

        boolean resolved = MqttCommandAckService.resolveMissingCommandId(ack, List.of(pending));

        assertThat(resolved).isTrue();
        assertThat(ack.getCommandId()).isEqualTo("cmd-1");
        assertThat(ack.getStatus()).isEqualTo("1");
    }

    @Test
    void resolveMissingCommandId_refusesAmbiguousPendingCommands() {
        DeviceCommandAck ack = new DeviceCommandAck();
        ack.setCommandId("");

        DeviceCommand first = new DeviceCommand();
        first.setCommandId("cmd-1");
        DeviceCommand second = new DeviceCommand();
        second.setCommandId("cmd-2");

        boolean resolved = MqttCommandAckService.resolveMissingCommandId(ack, List.of(first, second));

        assertThat(resolved).isFalse();
        assertThat(ack.getCommandId()).isEmpty();
    }

    @Test
    void ackMatchesPendingCommandRequiresSameDevice() {
        DeviceCommand pending = new DeviceCommand();
        pending.setDeviceNo("D01");

        assertThat(MqttCommandAckService.ackMatchesPendingCommand("D01", pending)).isTrue();
        assertThat(MqttCommandAckService.ackMatchesPendingCommand("D02", pending)).isFalse();
        assertThat(MqttCommandAckService.ackMatchesPendingCommand("D01", null)).isFalse();
    }

    @Test
    void clearPendingCommandsRemovesSnapshotBeforeDeletingPendingObjects() {
        MqttProperties properties = new MqttProperties();
        AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
        RedissonClient redissonClient = mock(RedissonClient.class);
        RSet<String> pendingIds = mock(RSet.class);
        Set<String> snapshot = new LinkedHashSet<>(List.of("cmd-1", "cmd-2"));
        AtomicBoolean snapshotRemoved = new AtomicBoolean();
        when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
        when(pendingIds.readAll()).thenReturn(snapshot);
        doAnswer(invocation -> {
            snapshotRemoved.set(true);
            return true;
        }).when(pendingIds).removeAll(snapshot);

        MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
        try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
            redis.when(RedisUtils::getClient).thenReturn(redissonClient);
            redis.when(() -> RedisUtils.deleteObject(any(String.class))).thenAnswer(invocation -> {
                assertThat(snapshotRemoved).isTrue();
                return true;
            });

            int cleared = service.clearPendingCommands();

            assertThat(cleared).isEqualTo(2);
            assertThat(service.isStartupCleanupReady()).isTrue();
            redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
            redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-2"));
            verify(pendingIds).removeAll(snapshot);
            verify(pendingIds, never()).clear();
        }
    }

    @Test
    void clearPendingCommandsOpensRetryGateForEmptySet() {
        MqttProperties properties = new MqttProperties();
        AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
        RedissonClient redissonClient = mock(RedissonClient.class);
        RSet<String> pendingIds = mock(RSet.class);
        Set<String> snapshot = Set.of();
        when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
        when(pendingIds.readAll()).thenReturn(snapshot);

        MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
        try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
            redis.when(RedisUtils::getClient).thenReturn(redissonClient);

            int cleared = service.clearPendingCommands();

            assertThat(cleared).isZero();
            assertThat(service.isStartupCleanupReady()).isTrue();
            verify(pendingIds).removeAll(snapshot);
            verify(pendingIds, never()).clear();
        }
    }

    @Test
    void clearPendingCommandsKeepsRetryGateClosedWhenSnapshotRemovalFails() {
        MqttProperties properties = new MqttProperties();
        AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
        RedissonClient redissonClient = mock(RedissonClient.class);
        RSet<String> pendingIds = mock(RSet.class);
        Set<String> snapshot = Set.of("cmd-1");
        when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
        when(pendingIds.readAll()).thenReturn(snapshot);
        doThrow(new IllegalStateException("redis unavailable")).when(pendingIds).removeAll(snapshot);

        MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
        try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
            redis.when(RedisUtils::getClient).thenReturn(redissonClient);

            assertThat(service.isStartupCleanupReady()).isFalse();
            assertThatThrownBy(service::clearPendingCommands)
                .isInstanceOf(IllegalStateException.class)
                .hasMessage("redis unavailable");

            assertThat(service.isStartupCleanupReady()).isFalse();
            redis.verify(() -> RedisUtils.deleteObject(any(String.class)), never());
            verify(pendingIds, never()).clear();
        }
    }

    @Test
    void clearPendingCommandsContinuesDeletingObjectsAndReportsFailures() {
        MqttProperties properties = new MqttProperties();
        AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
        RedissonClient redissonClient = mock(RedissonClient.class);
        RSet<String> pendingIds = mock(RSet.class);
        Set<String> snapshot = new LinkedHashSet<>(List.of("cmd-1", "cmd-2"));
        when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
        when(pendingIds.readAll()).thenReturn(snapshot);

        MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
        try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
            redis.when(RedisUtils::getClient).thenReturn(redissonClient);
            redis.when(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"))
                .thenThrow(new IllegalStateException("delete failed"));

            assertThatThrownBy(service::clearPendingCommands)
                .isInstanceOf(RuntimeException.class)
                .hasMessageContaining("cmd-1")
                .hasCauseInstanceOf(IllegalStateException.class);

            verify(pendingIds).removeAll(snapshot);
            verify(pendingIds, never()).clear();
            redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
            redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-2"));
            assertThat(service.isStartupCleanupReady()).isTrue();
        }
    }

    @Test
    void handleAckWaitsBrieflyForCommandLock() throws Exception {
        MqttProperties properties = new MqttProperties();
        AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
        RedissonClient redissonClient = mock(RedissonClient.class);
        RLock commandLock = mock(RLock.class);
        RLock statusLock = mock(RLock.class);
        RSet<String> pendingIds = mock(RSet.class);

        DeviceCommand pending = new DeviceCommand();
        pending.setCommandId("cmd-1");
        pending.setDeviceNo("D01");

        when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
        when(commandLock.tryLock(3000, 30000, TimeUnit.MILLISECONDS)).thenReturn(true);
        when(commandLock.isHeldByCurrentThread()).thenReturn(true);
        when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
        when(redissonClient.getLock("lock:mqtt:device:status:D01")).thenReturn(statusLock);
        when(statusLock.tryLock(0, 10, TimeUnit.SECONDS)).thenReturn(false);

        MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
        try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
            redis.when(RedisUtils::getClient).thenReturn(redissonClient);
            redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(pending);

            service.handleAck("D01", "{\"commandId\":\"cmd-1\",\"status\":\"1\"}");

            verify(commandLock).tryLock(3000, 30000, TimeUnit.MILLISECONDS);
            redis.verify(() -> RedisUtils.setCacheObject(
                eq("mqtt:command:ack:cmd-1"),
                any(DeviceCommandAck.class),
                eq(Duration.ofSeconds(86400))
            ));
            verify(pendingIds).remove("cmd-1");
            verify(commandLock).unlock();
        }
    }

    @Test
    void refreshDeviceOnlineRenewsStatusCacheTtl() throws Exception {
        MqttProperties properties = new MqttProperties();
        properties.getCommandAck().setDeviceStatusCacheTtlSeconds(900);
        AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
        RedissonClient redissonClient = mock(RedissonClient.class);
        RLock lock = mock(RLock.class);
        when(redissonClient.getLock("lock:mqtt:device:status:D01")).thenReturn(lock);
        when(lock.tryLock(0, 10, TimeUnit.SECONDS)).thenReturn(true);
        when(lock.isHeldByCurrentThread()).thenReturn(true);

        MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
        try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
            redis.when(RedisUtils::getClient).thenReturn(redissonClient);

            ReflectionTestUtils.invokeMethod(service, "refreshDeviceOnline", "D01");

            redis.verify(() -> RedisUtils.setCacheObject(
                eq("mqtt:device:status:D01"),
                any(Map.class),
                eq(Duration.ofSeconds(900))
            ));
            verify(appDeviceMapper).update(eq(null), any());
            verify(lock).unlock();
        }
    }
}
