package tech.easyflow.skill.imports;

import com.alicp.jetcache.AutoReleaseLock;
import com.alicp.jetcache.Cache;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.mockito.MockedStatic;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.SkillImportStage;
import tech.easyflow.skill.mapper.SkillImportStageMapper;

import java.math.BigInteger;
import java.util.Date;
import java.util.concurrent.TimeUnit;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
 * {@link SkillImportStageStore} 单次令牌、归属和清理边界测试。
 */
public class SkillImportStageStoreTest {

    private static final String TOKEN = "a".repeat(32);
    private static final BigInteger TENANT_ID = BigInteger.ONE;
    private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(7);

    /**
     * 验证归属正确的待处理令牌只能原子进入处理中状态。
     */
    @Test
    public void consumeMarksOwnedPendingStageAsProcessing() {
        Fixture fixture = fixture();
        SkillImportStage stage = pendingStage();
        when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(stage);
        when(fixture.mapper.consume(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID),
                any(Date.class), any(Date.class))).thenReturn(1);

        SkillImportStage consumed;
        try (MockedStatic<SaTokenUtil> login = login()) {
            consumed = fixture.store.consume(TOKEN);
        }

        assertEquals("PROCESSING", consumed.getStatus());
        assertTrue(consumed.getExpiresAt().after(new Date()));
        verify(fixture.cache).remove("skill:import:" + TOKEN);
    }

    /**
     * 验证其他用户或租户不能探测并消费已有令牌。
     */
    @Test
    public void consumeRejectsStageOwnedByAnotherAccount() {
        Fixture fixture = fixture();
        when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
        when(fixture.mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L);

        BusinessException exception;
        try (MockedStatic<SaTokenUtil> login = login()) {
            exception = assertThrows(BusinessException.class, () -> fixture.store.consume(TOKEN));
        }

        assertEquals(403, exception.getHttpStatus());
        verify(fixture.mapper, never()).consume(anyString(), any(), any(), any(), any());
    }

    /**
     * 验证过期令牌在状态更新前被拒绝。
     */
    @Test
    public void consumeRejectsExpiredStage() {
        Fixture fixture = fixture();
        SkillImportStage stage = pendingStage();
        stage.setExpiresAt(new Date(System.currentTimeMillis() - 1));
        when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(stage);

        try (MockedStatic<SaTokenUtil> login = login()) {
            assertThrows(BusinessException.class, () -> fixture.store.consume(TOKEN));
        }

        verify(fixture.mapper, never()).consume(anyString(), any(), any(), any(), any());
    }

    /**
     * 验证数据库原子更新失败时按重复或过期消费处理。
     */
    @Test
    public void consumeRejectsAlreadyConsumedStage() {
        Fixture fixture = fixture();
        when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pendingStage());
        when(fixture.mapper.consume(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID),
                any(Date.class), any(Date.class))).thenReturn(0);

        BusinessException exception;
        try (MockedStatic<SaTokenUtil> login = login()) {
            exception = assertThrows(BusinessException.class, () -> fixture.store.consume(TOKEN));
        }

        assertTrue(exception.getMessage().contains("已过期或已被使用"));
    }

    /**
     * 验证取消待处理会话会删除临时文件、数据库索引和缓存索引。
     */
    @Test
    public void cancelCleansOwnedPendingStage() {
        Fixture fixture = fixture();
        when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pendingStage());
        when(fixture.mapper.beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class))).thenReturn(1);
        when(fixture.mapper.finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID)).thenReturn(1);

        try (MockedStatic<SaTokenUtil> login = login()) {
            fixture.store.cancel(TOKEN);
        }

        verify(fixture.fileStorage).delete("skill-imports/demo.zip");
        verify(fixture.mapper).beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class));
        verify(fixture.mapper).finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID);
        verify(fixture.cache).remove("skill:import:" + TOKEN);
        org.mockito.InOrder order = inOrder(fixture.mapper, fixture.fileStorage);
        order.verify(fixture.mapper).beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class));
        order.verify(fixture.fileStorage).delete("skill-imports/demo.zip");
        order.verify(fixture.mapper).finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID);
    }

    /**
     * 验证文件删除失败时令牌已不可再消费，过期 PROCESSING 索引留给定时任务重试。
     */
    @Test
    public void cancelFileFailureLeavesExpiredNonConsumableStage() {
        Fixture fixture = fixture();
        when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pendingStage());
        when(fixture.mapper.beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class))).thenReturn(1);
        org.mockito.Mockito.doThrow(new RuntimeException("storage unavailable"))
                .when(fixture.fileStorage).delete("skill-imports/demo.zip");

        try (MockedStatic<SaTokenUtil> login = login()) {
            assertThrows(BusinessException.class, () -> fixture.store.cancel(TOKEN));
        }

        verify(fixture.mapper, never()).finishCancel(anyString(), any(), any());
        verify(fixture.cache).remove("skill:import:" + TOKEN);
    }

    /**
     * 验证重试取消时物理文件已经不存在也能继续删除遗留索引。
     */
    @Test
    public void cancelTreatsAlreadyAbsentFileAsSuccessfulCleanup() {
        Fixture fixture = fixture();
        when(fixture.mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(pendingStage());
        when(fixture.mapper.beginCancel(eq(TOKEN), eq(TENANT_ID), eq(ACCOUNT_ID), any(Date.class))).thenReturn(1);
        when(fixture.mapper.finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID)).thenReturn(1);
        org.mockito.Mockito.doThrow(new RuntimeException(
                        "already absent", new java.nio.file.NoSuchFileException("skill-imports/demo.zip")))
                .when(fixture.fileStorage).delete("skill-imports/demo.zip");

        try (MockedStatic<SaTokenUtil> login = login()) {
            fixture.store.cancel(TOKEN);
        }

        verify(fixture.mapper).finishCancel(TOKEN, TENANT_ID, ACCOUNT_ID);
        verify(fixture.cache).remove("skill:import:" + TOKEN);
    }

    /**
     * 导入会话索引写入失败属于服务端持久化故障，应返回 5xx。
     */
    @Test
    public void createPersistenceFailureUsesServerErrorStatus() {
        Fixture fixture = fixture();
        when(fixture.mapper.insert(any(SkillImportStage.class))).thenReturn(0);

        BusinessException exception;
        try (MockedStatic<SaTokenUtil> login = login()) {
            exception = assertThrows(BusinessException.class,
                    () -> fixture.store.create("skill-imports/demo.zip", "demo.zip"));
        }

        assertEquals(500, exception.getHttpStatus());
        verify(fixture.cache, never()).put(anyString(), any(), anyLong(), any(TimeUnit.class));
    }

    private Fixture fixture() {
        @SuppressWarnings("unchecked")
        Cache<String, Object> cache = mock(Cache.class);
        AutoReleaseLock lock = mock(AutoReleaseLock.class);
        when(cache.tryLock(anyString(), anyLong(), eq(TimeUnit.SECONDS))).thenReturn(lock);
        SkillImportStageMapper mapper = mock(SkillImportStageMapper.class);
        FileStorageService fileStorage = mock(FileStorageService.class);
        return new Fixture(cache, mapper, fileStorage,
                new SkillImportStageStore(cache, mapper, fileStorage));
    }

    private SkillImportStage pendingStage() {
        SkillImportStage stage = new SkillImportStage();
        stage.setImportToken(TOKEN);
        stage.setTenantId(TENANT_ID);
        stage.setAccountId(ACCOUNT_ID);
        stage.setFilePath("skill-imports/demo.zip");
        stage.setStatus("PENDING");
        stage.setExpiresAt(new Date(System.currentTimeMillis() + 60_000));
        return stage;
    }

    private MockedStatic<SaTokenUtil> login() {
        LoginAccount account = new LoginAccount();
        account.setId(ACCOUNT_ID);
        account.setTenantId(TENANT_ID);
        MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class);
        login.when(SaTokenUtil::getLoginAccount).thenReturn(account);
        return login;
    }

    private record Fixture(Cache<String, Object> cache,
                           SkillImportStageMapper mapper,
                           FileStorageService fileStorage,
                           SkillImportStageStore store) {
    }
}
