package com.starry.admin.api;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderRefundInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.enums.EarningsType;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.context.CustomSecurityContextHolder;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.function.Consumer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;

class PlayOrderInfoControllerApiTest extends AbstractApiTest {

    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    private static final BigDecimal DEFAULT_AMOUNT = new BigDecimal("188.00");

    @Autowired
    private IPlayOrderInfoService orderInfoService;

    @Autowired
    private IEarningsService earningsService;

    @Autowired
    private IPlayOrderRefundInfoService orderRefundInfoService;

    private final ObjectMapper objectMapper = new ObjectMapper();
    private final List<String> orderIdsToCleanup = new ArrayList<>();
    private final List<String> earningsLineIdsToCleanup = new ArrayList<>();
    private final List<String> refundIdsToCleanup = new ArrayList<>();

    @AfterEach
    void tearDown() {
        ensureTenantContext();
        if (!earningsLineIdsToCleanup.isEmpty()) {
            earningsService.removeByIds(earningsLineIdsToCleanup);
            earningsLineIdsToCleanup.clear();
        }
        if (!refundIdsToCleanup.isEmpty()) {
            orderRefundInfoService.removeByIds(refundIdsToCleanup);
            refundIdsToCleanup.clear();
        }
        if (!orderIdsToCleanup.isEmpty()) {
            orderInfoService.removeByIds(orderIdsToCleanup);
            orderIdsToCleanup.clear();
        }
        CustomSecurityContextHolder.remove();
    }

    @Test
    void listByPage_honorsAllSupportedFilters() throws Exception {
        ensureTenantContext();
        String marker = ("FT" + IdUtils.getUuid().replace("-", "").substring(0, 6)).toUpperCase();
        LocalDateTime reference = LocalDateTime.now().minusHours(6).withNano(0);

        PlayOrderInfoEntity matching = persistOrder(marker, "match", reference, order -> {
            order.setOrderStatus("3");
            order.setPlaceType("2");
            order.setPayMethod("2");
            order.setUseCoupon("1");
            order.setBackendEntry("1");
            order.setFirstOrder("0");
            order.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID);
            order.setSex("2");
            order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
            order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
        });

        persistOrder(marker, "noise", reference.minusDays(3), order -> {
            order.setOrderStatus("0");
            order.setPlaceType("0");
            order.setPayMethod("0");
            order.setUseCoupon("0");
            order.setBackendEntry("0");
            order.setFirstOrder("1");
            order.setGroupId(marker + "-grp");
            order.setSex("1");
            order.setAcceptBy(null);
            order.setPurchaserBy("customer-" + marker);
        });

        // orderNo filter (exact)
        ObjectNode orderNoPayload = baseQuery();
        orderNoPayload.put("orderNo", matching.getOrderNo());
        assertFilterMatches(orderNoPayload, matching.getId());

        // acceptBy filter
        ObjectNode acceptPayload = queryWithMarker(marker);
        acceptPayload.put("acceptBy", matching.getAcceptBy());
        assertFilterMatches(acceptPayload, matching.getId());

        // purchaserBy filter
        ObjectNode purchaserPayload = queryWithMarker(marker);
        purchaserPayload.put("purchaserBy", matching.getPurchaserBy());
        assertFilterMatches(purchaserPayload, matching.getId());

        // orderStatus filter
        ObjectNode statusPayload = queryWithMarker(marker);
        statusPayload.put("orderStatus", matching.getOrderStatus());
        assertFilterMatches(statusPayload, matching.getId());

        // placeType filter
        ObjectNode placePayload = queryWithMarker(marker);
        placePayload.put("placeType", matching.getPlaceType());
        assertFilterMatches(placePayload, matching.getId());

        // payMethod filter
        ObjectNode payPayload = queryWithMarker(marker);
        payPayload.put("payMethod", matching.getPayMethod());
        assertFilterMatches(payPayload, matching.getId());

        // useCoupon filter
        ObjectNode couponPayload = queryWithMarker(marker);
        couponPayload.put("useCoupon", matching.getUseCoupon());
        assertFilterMatches(couponPayload, matching.getId());

        // backendEntry filter
        ObjectNode backendPayload = queryWithMarker(marker);
        backendPayload.put("backendEntry", matching.getBackendEntry());
        assertFilterMatches(backendPayload, matching.getId());

        // firstOrder filter
        ObjectNode firstOrderPayload = queryWithMarker(marker);
        firstOrderPayload.put("firstOrder", matching.getFirstOrder());
        assertFilterMatches(firstOrderPayload, matching.getId());

        // groupId filter
        ObjectNode groupPayload = queryWithMarker(marker);
        groupPayload.put("groupId", matching.getGroupId());
        assertFilterMatches(groupPayload, matching.getId());

        // sex filter
        ObjectNode sexPayload = queryWithMarker(marker);
        sexPayload.put("sex", matching.getSex());
        assertFilterMatches(sexPayload, matching.getId());

        // purchaserTime range filter
        ObjectNode purchaserTimePayload = queryWithMarker(marker);
        purchaserTimePayload.set("purchaserTime", range(
                reference.minusMinutes(30),
                reference.plusMinutes(30)));
        assertFilterMatches(purchaserTimePayload, matching.getId());

        // acceptTime range filter
        ObjectNode acceptTimePayload = queryWithMarker(marker);
        acceptTimePayload.set("acceptTime", range(
                matching.getAcceptTime().minusMinutes(15),
                matching.getAcceptTime().plusMinutes(15)));
        assertFilterMatches(acceptTimePayload, matching.getId());

        // endOrderTime range filter
        ObjectNode endTimePayload = queryWithMarker(marker);
        endTimePayload.set("endOrderTime", range(
                matching.getOrderEndTime().minusMinutes(15),
                matching.getOrderEndTime().plusMinutes(15)));
        assertFilterMatches(endTimePayload, matching.getId());

        // Combined filters to verify logical AND behaviour
        ObjectNode combinedPayload = queryWithMarker(marker);
        combinedPayload.put("acceptBy", matching.getAcceptBy());
        combinedPayload.put("purchaserBy", matching.getPurchaserBy());
        combinedPayload.put("orderStatus", matching.getOrderStatus());
        combinedPayload.put("placeType", matching.getPlaceType());
        combinedPayload.put("payMethod", matching.getPayMethod());
        combinedPayload.put("useCoupon", matching.getUseCoupon());
        combinedPayload.put("backendEntry", matching.getBackendEntry());
        combinedPayload.put("firstOrder", matching.getFirstOrder());
        combinedPayload.put("groupId", matching.getGroupId());
        combinedPayload.put("sex", matching.getSex());
        combinedPayload.set("purchaserTime", range(
                reference.minusMinutes(5),
                reference.plusMinutes(5)));
        combinedPayload.set("acceptTime", range(
                matching.getAcceptTime().minusMinutes(5),
                matching.getAcceptTime().plusMinutes(5)));
        combinedPayload.set("endOrderTime", range(
                matching.getOrderEndTime().minusMinutes(5),
                matching.getOrderEndTime().plusMinutes(5)));
        assertFilterMatches(combinedPayload, matching.getId());
    }

    @Test
    void listByPage_keywordFiltersByOrderNoOrClerkName() throws Exception {
        ensureTenantContext();
        String marker = ("KW" + IdUtils.getUuid().replace("-", "").substring(0, 4)).toUpperCase();
        LocalDateTime reference = LocalDateTime.now().plusHours(2);

        PlayOrderInfoEntity orderByNo = persistOrder(marker, "ord", reference, order -> {
            order.setOrderStatus(OrderStatus.COMPLETED.getCode());
        });
        PlayOrderInfoEntity orderByClerk = persistOrder(marker, "clk", reference.plusMinutes(10), order -> {
            order.setOrderStatus(OrderStatus.COMPLETED.getCode());
        });

        ObjectNode orderNoPayload = baseQuery();
        orderNoPayload.put("keyword", orderByNo.getOrderNo());
        assertFilterMatches(orderNoPayload, orderByNo.getId());

        ObjectNode clerkKeywordPayload = baseQuery();
        clerkKeywordPayload.put("keyword", "小测官");
        clerkKeywordPayload.set("purchaserTime", range(reference.plusMinutes(5), reference.plusMinutes(15)));
        clerkKeywordPayload.put("placeType", "1");
        RecordsResponse clerkResponse = executeList(clerkKeywordPayload);
        JsonNode clerkRecords = clerkResponse.records;
        assertThat(clerkRecords.size()).isGreaterThan(0);
        List<String> ids = new ArrayList<>();
        clerkRecords.forEach(node -> ids.add(node.path("id").asText()));
        assertThat(ids).contains(orderByClerk.getId());
    }

    @Test
    void listByPage_keywordRespectsAdditionalFilters() throws Exception {
        ensureTenantContext();
        String marker = ("KWFLT" + IdUtils.getUuid().replace("-", "").substring(0, 4)).toUpperCase();
        LocalDateTime reference = LocalDateTime.now().plusHours(3);

        PlayOrderInfoEntity assignedOrder = persistOrder(marker, "assigned", reference, order -> {
            order.setOrderStatus("3");
            order.setPlaceType("0");
        });

        persistOrder(marker, "random", reference.minusMinutes(20), order -> {
            order.setOrderStatus("3");
            order.setPlaceType("1");
        });

        ObjectNode keywordAndFilterPayload = baseQuery();
        keywordAndFilterPayload.put("keyword", "小测官");
        keywordAndFilterPayload.put("placeType", "0");
        keywordAndFilterPayload.set("purchaserTime", range(reference.minusMinutes(2), reference.plusMinutes(2)));

        RecordsResponse filteredResponse = executeList(keywordAndFilterPayload);
        JsonNode records = filteredResponse.records;
        assertThat(records.size()).isEqualTo(1);
        assertThat(records.get(0).path("id").asText()).isEqualTo(assignedOrder.getId());
    }

    @Test
    void revokeCompletedOrder_keepEarningsIgnoresLockedLines() throws Exception {
        ensureTenantContext();
        LocalDateTime reference = LocalDateTime.now().minusHours(1);
        PlayOrderInfoEntity order = persistOrder("RVK", "keep", reference, entity -> {
            entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
            entity.setFinalAmount(new BigDecimal("166.00"));
        });
        seedEarningLine(order.getId(), new BigDecimal("80.00"), "withdrawn");

        ObjectNode payload = objectMapper.createObjectNode();
        payload.put("orderId", order.getId());
        payload.put("refundToCustomer", false);
        payload.put("refundAmount", BigDecimal.ZERO);
        payload.put("refundReason", "API撤销-保留收益");
        payload.put("deductClerkEarnings", false);

        MvcResult response = mockMvc.perform(post("/order/order/revokeCompleted")
                        .contentType(MediaType.APPLICATION_JSON)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(USER_HEADER, DEFAULT_USER)
                        .content(payload.toString()))
                .andExpect(status().isOk())
                .andReturn();

        JsonNode reverseRoot = objectMapper.readTree(response.getResponse().getContentAsString());
        assertThat(reverseRoot.path("code").asInt())
                .as("response=%s", reverseRoot.toString())
                .isEqualTo(200);
        assertThat(reverseRoot.path("success").asBoolean()).isTrue();

    }

    @Test
    void revokeCompletedOrder_reverseClerkCreatesNegativeLineEvenWhenLocked() throws Exception {
        ensureTenantContext();
        LocalDateTime reference = LocalDateTime.now().minusHours(2);
        PlayOrderInfoEntity order = persistOrder("RVK", "reverse", reference, entity -> {
            entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
            entity.setFinalAmount(new BigDecimal("210.00"));
        });
        seedEarningLine(order.getId(), new BigDecimal("120.00"), "withdrawn");

        ObjectNode payload = objectMapper.createObjectNode();
        payload.put("orderId", order.getId());
        payload.put("refundToCustomer", false);
        payload.put("refundAmount", new BigDecimal("20.00"));
        payload.put("refundReason", "API撤销-冲销收益");
        payload.put("deductClerkEarnings", true);
        payload.put("deductAmount", new BigDecimal("20.00"));

        MvcResult response = mockMvc.perform(post("/order/order/revokeCompleted")
                        .contentType(MediaType.APPLICATION_JSON)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(USER_HEADER, DEFAULT_USER)
                        .content(payload.toString()))
                .andExpect(status().isOk())
                .andReturn();

        JsonNode reverseRoot = objectMapper.readTree(response.getResponse().getContentAsString());
        assertThat(reverseRoot.path("code").asInt())
                .as("response=%s", reverseRoot.toString())
                .isEqualTo(200);
        assertThat(reverseRoot.path("success").asBoolean()).isTrue();

        ensureTenantContext();
        List<EarningsLineEntity> lines = earningsService.lambdaQuery()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .list();
        assertThat(lines).hasSize(2);
        EarningsLineEntity negativeLine = lines.stream()
                .filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
                .findFirst()
                .orElseThrow(() -> new AssertionError("未生成负收益行"));
        assertThat(negativeLine.getAmount()).isEqualByComparingTo(new BigDecimal("-20.00"));
        assertThat(negativeLine.getClerkId()).isEqualTo(order.getAcceptBy());
        earningsLineIdsToCleanup.add(negativeLine.getId());
    }

    @Test
    void revokeCompletedOrder_deductKeepsFrozenUnlockSchedule() throws Exception {
        ensureTenantContext();
        LocalDateTime reference = LocalDateTime.now().minusHours(1);
        PlayOrderInfoEntity order = persistOrder("RVK", "frozen", reference, entity -> {
            entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
            entity.setFinalAmount(new BigDecimal("166.00"));
        });
        earningsService.lambdaUpdate()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .remove();
        LocalDateTime unlockAt = LocalDateTime.now().plusHours(3).withNano(0);
        seedEarningLine(order.getId(), new BigDecimal("90.00"), "frozen", unlockAt);

        ObjectNode payload = objectMapper.createObjectNode();
        payload.put("orderId", order.getId());
        payload.put("refundToCustomer", false);
        payload.put("refundAmount", BigDecimal.ZERO);
        payload.put("refundReason", "API撤销-冻结扣回");
        payload.put("deductClerkEarnings", true);
        payload.put("deductAmount", new BigDecimal("30.00"));

        LocalDateTime beforeCall = LocalDateTime.now().minusSeconds(1);
        mockMvc.perform(post("/order/order/revokeCompleted")
                        .contentType(MediaType.APPLICATION_JSON)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(USER_HEADER, DEFAULT_USER)
                        .content(payload.toString()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200));

        ensureTenantContext();
        EarningsLineEntity negativeLine = earningsService.lambdaQuery()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .list()
                .stream()
                .filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
                .findFirst()
                .orElseThrow(() -> new AssertionError("未生成负收益行"));
        assertThat(negativeLine.getStatus()).isEqualTo("frozen");
        assertThat(negativeLine.getUnlockTime()).isEqualTo(unlockAt);
        earningsLineIdsToCleanup.add(negativeLine.getId());
    }

    @Test
    void revokeCompletedOrder_deductMakesWithdrawnLineAvailable() throws Exception {
        ensureTenantContext();
        LocalDateTime reference = LocalDateTime.now().minusHours(2);
        PlayOrderInfoEntity order = persistOrder("RVK", "withdrawn", reference, entity -> {
            entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
            entity.setFinalAmount(new BigDecimal("188.00"));
        });
        earningsService.lambdaUpdate()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .remove();
        seedEarningLine(order.getId(), new BigDecimal("120.00"), "withdrawn");

        ObjectNode payload = objectMapper.createObjectNode();
        payload.put("orderId", order.getId());
        payload.put("refundToCustomer", false);
        payload.put("refundAmount", new BigDecimal("0.00"));
        payload.put("refundReason", "API撤销-提现扣回");
        payload.put("deductClerkEarnings", true);
        payload.put("deductAmount", new BigDecimal("40.00"));

        LocalDateTime beforeCall = LocalDateTime.now().minusSeconds(1);
        mockMvc.perform(post("/order/order/revokeCompleted")
                        .contentType(MediaType.APPLICATION_JSON)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(USER_HEADER, DEFAULT_USER)
                        .content(payload.toString()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200));

        ensureTenantContext();
        EarningsLineEntity negativeLine = earningsService.lambdaQuery()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .list()
                .stream()
                .filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
                .findFirst()
                .orElseThrow(() -> new AssertionError("未生成负收益行"));
        assertThat(negativeLine.getStatus()).isEqualTo("available");
        assertThat(negativeLine.getUnlockTime()).isAfter(beforeCall);
        earningsLineIdsToCleanup.add(negativeLine.getId());
    }

    @Test
    void revokeCompletedOrder_defaultsDeductAmountWhenMissing() throws Exception {
        ensureTenantContext();
        LocalDateTime reference = LocalDateTime.now().minusMinutes(50);
        PlayOrderInfoEntity order = persistOrder("RVK", "autoDeduct", reference, entity -> {
            entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
            entity.setFinalAmount(new BigDecimal("260.00"));
        });
        earningsService.lambdaUpdate()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .remove();
        String earningId = seedEarningLine(order.getId(), new BigDecimal("75.00"), "available");
        earningsLineIdsToCleanup.add(earningId);

        ObjectNode payload = objectMapper.createObjectNode();
        payload.put("orderId", order.getId());
        payload.put("refundToCustomer", false);
        payload.put("refundReason", "API撤销-自动扣回");
        payload.put("deductClerkEarnings", true);

        mockMvc.perform(post("/order/order/revokeCompleted")
                        .contentType(MediaType.APPLICATION_JSON)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(USER_HEADER, DEFAULT_USER)
                        .content(payload.toString()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200));

        ensureTenantContext();
        List<EarningsLineEntity> lines = earningsService.lambdaQuery()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .list();
        EarningsLineEntity counterLine = lines.stream()
                .filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
                .findFirst()
                .orElseThrow(() -> new AssertionError("未生成负收益行"));
        assertThat(counterLine.getAmount()).isEqualByComparingTo(new BigDecimal("-75.00"));
        earningsLineIdsToCleanup.add(counterLine.getId());
    }

    @Test
    void revokeCompletedOrder_refundAndDeductCreatesRecords() throws Exception {
        ensureTenantContext();
        LocalDateTime reference = LocalDateTime.now().minusMinutes(35);
        PlayOrderInfoEntity order = persistOrder("RVK", "refundDeduct", reference, entity -> {
            entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
            entity.setFinalAmount(new BigDecimal("300.00"));
            entity.setPaymentSource(OrderConstant.PaymentSource.WX_PAY.getCode());
        });
        earningsService.lambdaUpdate()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .remove();
        String earningId = seedEarningLine(order.getId(), new BigDecimal("120.00"), "available");
        earningsLineIdsToCleanup.add(earningId);

        ObjectNode payload = objectMapper.createObjectNode();
        payload.put("orderId", order.getId());
        payload.put("refundToCustomer", true);
        payload.put("refundAmount", new BigDecimal("80.00"));
        payload.put("refundReason", "API撤销-退款扣回");
        payload.put("deductClerkEarnings", true);
        payload.put("deductAmount", new BigDecimal("60.00"));

        mockMvc.perform(post("/order/order/revokeCompleted")
                        .contentType(MediaType.APPLICATION_JSON)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(USER_HEADER, DEFAULT_USER)
                        .content(payload.toString()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200));

        ensureTenantContext();
        PlayOrderRefundInfoEntity refundInfo = orderRefundInfoService.selectPlayOrderRefundInfoByOrderId(order.getId());
        assertThat(refundInfo).isNotNull();
        assertThat(refundInfo.getRefundAmount()).isEqualByComparingTo(new BigDecimal("80.00"));
        refundIdsToCleanup.add(refundInfo.getId());

        List<EarningsLineEntity> lines = earningsService.lambdaQuery()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .list();
        EarningsLineEntity counterLine = lines.stream()
                .filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
                .findFirst()
                .orElseThrow(() -> new AssertionError("未生成负收益行"));
        assertThat(counterLine.getAmount()).isEqualByComparingTo(new BigDecimal("-60.00"));
        earningsLineIdsToCleanup.add(counterLine.getId());
    }

    @Test
    void revokeCompletedOrder_deductLineRespectsFutureUnlockSchedule() throws Exception {
        ensureTenantContext();
        LocalDateTime reference = LocalDateTime.now().minusHours(3).withNano(0);
        LocalDateTime unlockAt = LocalDateTime.now().plusHours(12).withNano(0);
        PlayOrderInfoEntity order = persistOrder("RVK", "futureUnlock", reference, entity -> {
            entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
            entity.setFinalAmount(new BigDecimal("220.00"));
        });
        earningsService.lambdaUpdate()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .remove();
        String earningId = seedEarningLine(order.getId(), new BigDecimal("120.00"), "frozen", unlockAt);
        earningsLineIdsToCleanup.add(earningId);

        ObjectNode payload = objectMapper.createObjectNode();
        payload.put("orderId", order.getId());
        payload.put("refundToCustomer", false);
        payload.put("refundReason", "API撤销-锁定排期");
        payload.put("deductClerkEarnings", true);
        payload.put("deductAmount", new BigDecimal("60.00"));

        mockMvc.perform(post("/order/order/revokeCompleted")
                        .contentType(MediaType.APPLICATION_JSON)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(USER_HEADER, DEFAULT_USER)
                        .content(payload.toString()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200));

        ensureTenantContext();
        List<EarningsLineEntity> lines = earningsService.lambdaQuery()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .list();
        EarningsLineEntity counterLine = lines.stream()
                .filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
                .findFirst()
                .orElseThrow(() -> new AssertionError("未生成负收益行"));
        assertThat(counterLine.getStatus()).isEqualTo("frozen");
        assertThat(counterLine.getUnlockTime()).isEqualTo(unlockAt);
        earningsLineIdsToCleanup.add(counterLine.getId());
    }

    @Test
    void revokeCompletedOrder_deductLineUnlocksImmediatelyWhenAlreadyAvailable() throws Exception {
        ensureTenantContext();
        LocalDateTime reference = LocalDateTime.now().minusHours(5).withNano(0);
        LocalDateTime unlockAt = LocalDateTime.now().minusHours(1).withNano(0);
        PlayOrderInfoEntity order = persistOrder("RVK", "pastUnlock", reference, entity -> {
            entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
            entity.setFinalAmount(new BigDecimal("180.00"));
        });
        earningsService.lambdaUpdate()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .remove();
        String earningId = seedEarningLine(order.getId(), new BigDecimal("90.00"), "available", unlockAt);
        earningsLineIdsToCleanup.add(earningId);

        ObjectNode payload = objectMapper.createObjectNode();
        payload.put("orderId", order.getId());
        payload.put("refundToCustomer", false);
        payload.put("refundReason", "API撤销-立即扣回");
        payload.put("deductClerkEarnings", true);
        payload.put("deductAmount", new BigDecimal("45.00"));

        LocalDateTime beforeCall = LocalDateTime.now().minusSeconds(1);
        mockMvc.perform(post("/order/order/revokeCompleted")
                        .contentType(MediaType.APPLICATION_JSON)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(USER_HEADER, DEFAULT_USER)
                        .content(payload.toString()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200));

        ensureTenantContext();
        List<EarningsLineEntity> lines = earningsService.lambdaQuery()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .list();
        EarningsLineEntity counterLine = lines.stream()
                .filter(line -> line.getAmount().compareTo(BigDecimal.ZERO) < 0)
                .findFirst()
                .orElseThrow(() -> new AssertionError("未生成负收益行"));
        assertThat(counterLine.getStatus()).isEqualTo("available");
        assertThat(counterLine.getUnlockTime()).isAfter(beforeCall);
        earningsLineIdsToCleanup.add(counterLine.getId());
    }

    @Test
    void revokeCompletedOrder_deductFailsWhenNoEarningLineExists() throws Exception {
        ensureTenantContext();
        LocalDateTime reference = LocalDateTime.now().minusHours(4);
        PlayOrderInfoEntity order = persistOrder("RVK", "noLine", reference, entity -> {
            entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
            entity.setFinalAmount(new BigDecimal("150.00"));
        });
        earningsService.lambdaUpdate()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .remove();

        ObjectNode payload = objectMapper.createObjectNode();
        payload.put("orderId", order.getId());
        payload.put("refundToCustomer", false);
        payload.put("refundReason", "API撤销-无收益扣回");
        payload.put("deductClerkEarnings", true);
        payload.put("deductAmount", new BigDecimal("30.00"));

        mockMvc.perform(post("/order/order/revokeCompleted")
                        .contentType(MediaType.APPLICATION_JSON)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(USER_HEADER, DEFAULT_USER)
                        .content(payload.toString()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(500))
                .andExpect(jsonPath("$.message").value("本单店员收益已全部扣回"));
    }

    @Test
    void revokeCompletedOrder_rejectsDeductAmountBeyondAvailable() throws Exception {
        ensureTenantContext();
        LocalDateTime reference = LocalDateTime.now().minusMinutes(30);
        PlayOrderInfoEntity order = persistOrder("RVK", "overDeduct", reference, entity -> {
            entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
        });
        earningsService.lambdaUpdate()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .remove();
        String earningId = seedEarningLine(order.getId(), new BigDecimal("40.00"), "available");
        earningsLineIdsToCleanup.add(earningId);

        ObjectNode payload = objectMapper.createObjectNode();
        payload.put("orderId", order.getId());
        payload.put("refundToCustomer", false);
        payload.put("refundReason", "API撤销-超额扣");
        payload.put("deductClerkEarnings", true);
        payload.put("deductAmount", new BigDecimal("60.00"));

        mockMvc.perform(post("/order/order/revokeCompleted")
                        .contentType(MediaType.APPLICATION_JSON)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(USER_HEADER, DEFAULT_USER)
                        .content(payload.toString()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(500))
                .andExpect(jsonPath("$.message").value("扣回金额不能超过本单收益40.00"));
    }

    @Test
    void revokeCompletedOrder_blocksNonNormalOrders() throws Exception {
        ensureTenantContext();
        LocalDateTime reference = LocalDateTime.now().minusMinutes(10);
        PlayOrderInfoEntity giftOrder = persistOrder("RVK", "gift", reference, entity -> {
            entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
            entity.setOrderType(OrderConstant.OrderType.GIFT.getCode());
            entity.setPlaceType(OrderConstant.PlaceType.REWARD.getCode());
        });

        ObjectNode payload = objectMapper.createObjectNode();
        payload.put("orderId", giftOrder.getId());
        payload.put("refundToCustomer", false);
        payload.put("refundReason", "gift revoke");
        payload.put("deductClerkEarnings", false);

        mockMvc.perform(post("/order/order/revokeCompleted")
                        .contentType(MediaType.APPLICATION_JSON)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(USER_HEADER, DEFAULT_USER)
                        .content(payload.toString()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(500))
                .andExpect(jsonPath("$.message").value("仅支持撤销普通服务订单"));
    }

    @Test
    void getRevocationLimits_returnsRemainingValues() throws Exception {
        ensureTenantContext();
        PlayOrderInfoEntity order = persistOrder("RVK", "limits", LocalDateTime.now().minusHours(3), entity -> {
            entity.setOrderStatus(OrderStatus.COMPLETED.getCode());
            entity.setFinalAmount(new BigDecimal("188.00"));
        });
        earningsService.lambdaUpdate()
                .eq(EarningsLineEntity::getOrderId, order.getId())
                .remove();
        String earningId = seedEarningLine(order.getId(), new BigDecimal("45.50"), "available");
        earningsLineIdsToCleanup.add(earningId);

        mockMvc.perform(get("/order/order/" + order.getId() + "/revocationLimits")
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(USER_HEADER, DEFAULT_USER))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200))
                .andExpect(jsonPath("$.data.maxRefundAmount").value(188.00))
                .andExpect(jsonPath("$.data.maxDeductAmount").value(45.50))
                .andExpect(jsonPath("$.data.defaultDeductAmount").value(45.50))
                .andExpect(jsonPath("$.data.deductible").value(true));
    }

    private PlayOrderInfoEntity persistOrder(
            String marker,
            String token,
            LocalDateTime purchaserTime,
            Consumer<PlayOrderInfoEntity> customizer) {
        PlayOrderInfoEntity order = buildBaselineOrder(marker, token, purchaserTime);
        customizer.accept(order);
        assertThat(orderInfoService.save(order))
                .withFailMessage("Failed to persist order %s", order.getOrderNo())
                .isTrue();
        orderIdsToCleanup.add(order.getId());
        return order;
    }

    private PlayOrderInfoEntity buildBaselineOrder(String marker, String token, LocalDateTime purchaserTime) {
        PlayOrderInfoEntity order = new PlayOrderInfoEntity();
        order.setId("order-" + token + "-" + IdUtils.getUuid().substring(0, 8));
        String tokenFragment = token.length() >= 2 ? token.substring(0, 2) : token;
        order.setOrderNo(marker + tokenFragment.toUpperCase() + IdUtils.getUuid().substring(0, 4));
        order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
        order.setOrderType("2");
        order.setPlaceType("1");
        order.setRewardType("0");
        order.setFirstOrder("0");
        order.setRefundType("0");
        order.setRefundAmount(BigDecimal.ZERO);
        order.setRefundReason(null);
        order.setOrderMoney(DEFAULT_AMOUNT);
        order.setDiscountAmount(BigDecimal.ZERO);
        order.setFinalAmount(DEFAULT_AMOUNT);
        order.setEstimatedRevenue(new BigDecimal("88.00"));
        order.setEstimatedRevenueRatio(50);
        order.setLabels(Collections.singletonList("label-" + marker));
        order.setUseCoupon("1");
        order.setCouponIds(Collections.singletonList("coupon-" + marker));
        order.setBackendEntry("1");
        order.setPaymentSource("balance");
        order.setPayMethod("2");
        order.setPayState("2");
        order.setWeiChatCode("wx-" + marker);
        order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
        order.setPurchaserTime(purchaserTime);
        order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
        order.setAcceptTime(purchaserTime.plusMinutes(20));
        order.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID);
        order.setOrderStartTime(purchaserTime.plusMinutes(30));
        order.setOrderEndTime(purchaserTime.plusHours(2));
        order.setOrderSettlementState("0");
        order.setOrdersExpiredState("0");
        order.setSex("2");
        order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
        order.setCommodityType("1");
        order.setCommodityPrice(DEFAULT_AMOUNT);
        order.setCommodityName("API Filter Commodity");
        order.setServiceDuration("60min");
        order.setCommodityNumber("1");
        order.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
        order.setExcludeHistory("0");
        order.setRemark("marker-" + marker);
        order.setBackendRemark("backend-" + marker);
        order.setProfitSharingAmount(BigDecimal.ZERO);
        order.setDeleted(Boolean.FALSE);
        order.setCreatedBy("apitest");
        order.setUpdatedBy("apitest");
        order.setCreatedTime(toDate(purchaserTime));
        order.setUpdatedTime(toDate(purchaserTime.plusMinutes(45)));
        return order;
    }

    private ObjectNode baseQuery() {
        ObjectNode node = objectMapper.createObjectNode();
        node.put("pageNum", 1);
        node.put("pageSize", 20);
        return node;
    }

    private ObjectNode queryWithMarker(String marker) {
        ObjectNode node = baseQuery();
        node.put("orderNo", marker);
        return node;
    }

    private ArrayNode range(LocalDateTime start, LocalDateTime end) {
        ArrayNode array = objectMapper.createArrayNode();
        array.add(DATE_TIME_FORMATTER.format(start));
        array.add(DATE_TIME_FORMATTER.format(end));
        return array;
    }

    private String seedEarningLine(String orderId, BigDecimal amount, String status) {
        return seedEarningLine(orderId, amount, status, LocalDateTime.now().minusHours(2).withNano(0));
    }

    private String seedEarningLine(String orderId, BigDecimal amount, String status, LocalDateTime unlockAt) {
        EarningsLineEntity entity = new EarningsLineEntity();
        String id = "earn-revoke-" + IdUtils.getUuid();
        entity.setId(id);
        entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
        entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
        entity.setOrderId(orderId);
        entity.setAmount(amount);
        entity.setStatus(status);
        entity.setEarningType(EarningsType.ORDER);
        entity.setUnlockTime(unlockAt);
        if ("withdrawn".equals(status) || "withdrawing".equals(status)) {
            entity.setWithdrawalId("withdraw-" + IdUtils.getUuid());
        }
        Date nowDate = toDate(LocalDateTime.now());
        entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
        entity.setCreatedTime(nowDate);
        entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
        entity.setUpdatedTime(nowDate);
        entity.setDeleted(false);
        ensureTenantContext();
        earningsService.save(entity);
        earningsLineIdsToCleanup.add(id);
        return id;
    }

    private void assertFilterMatches(ObjectNode payload, String expectedOrderId) throws Exception {
        RecordsResponse response = executeList(payload);
        JsonNode records = response.records;
        assertThat(records.isArray())
                .withFailMessage("Records payload is not an array for body=%s | response=%s", payload, response.rawResponse)
                .isTrue();
        assertThat(records.size())
                .withFailMessage("Unexpected record count for body=%s | response=%s", payload, response.rawResponse)
                .isEqualTo(1);
        assertThat(records.get(0).path("id").asText())
                .withFailMessage("Unexpected order id for body=%s | response=%s", payload, response.rawResponse)
                .isEqualTo(expectedOrderId);
    }

    private RecordsResponse executeList(ObjectNode payload) throws Exception {
        MvcResult result = mockMvc.perform(post("/order/order/listByPage")
                        .contentType(MediaType.APPLICATION_JSON)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(USER_HEADER, DEFAULT_USER)
                        .content(payload.toString()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200))
                .andReturn();

        String responseBody = result.getResponse().getContentAsString();
        JsonNode root = objectMapper.readTree(responseBody);
        JsonNode data = root.path("data");
        JsonNode records = data.isArray() ? data : data.path("records");
        return new RecordsResponse(records, responseBody);
    }

    private Date toDate(LocalDateTime time) {
        return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
    }

    private void ensureTenantContext() {
        SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
    }

    private static class RecordsResponse {
        private final JsonNode records;
        private final String rawResponse;

        private RecordsResponse(JsonNode records, String rawResponse) {
            this.records = records;
            this.rawResponse = rawResponse;
        }
    }
}
