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.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.weichat.service.WxTokenService;
import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
import com.starry.admin.modules.withdraw.enums.EarningsType;
import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.constant.Constants;
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.Date;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;

class WxWithdrawControllerApiTest extends AbstractApiTest {

    private static final DateTimeFormatter DATE_TIME_FORMATTER =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    private static final String ORDER_ID_PREFIX = "earn-order-";

    @Autowired
    private IEarningsService earningsService;

    @Autowired
    private IWithdrawalService withdrawalService;

    @MockBean
    private IClerkPayeeProfileService clerkPayeeProfileService;

    @Autowired
    private IPlayClerkUserInfoService clerkUserInfoService;

    @Autowired
    private WxTokenService wxTokenService;

    private final List<String> earningsToCleanup = new ArrayList<>();
    private final List<String> withdrawalsToCleanup = new ArrayList<>();
    private final ObjectMapper objectMapper = new ObjectMapper();
    private String clerkToken;
    private ClerkPayeeProfileEntity payeeProfile;

    @BeforeEach
    void setUp() {
        ensureTenantContext();
        // reset seeded data to a clean state for deterministic assertions
        earningsService.lambdaUpdate()
                .eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
                .remove();
        withdrawalService.lambdaUpdate()
                .eq(WithdrawalRequestEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
                .remove();
        LocalDateTime now = LocalDateTime.now();
        payeeProfile = new ClerkPayeeProfileEntity();
        payeeProfile.setId("payee-" + IdUtils.getUuid());
        payeeProfile.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
        payeeProfile.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
        payeeProfile.setChannel("ALIPAY_QR");
        payeeProfile.setQrCodeUrl("https://example.com/test-payee.png");
        payeeProfile.setDisplayName("API测试收款码");
        payeeProfile.setLastConfirmedAt(now);

        Mockito.when(clerkPayeeProfileService.getByClerk(
                        ApiTestDataSeeder.DEFAULT_TENANT_ID, ApiTestDataSeeder.DEFAULT_CLERK_ID))
                .thenAnswer(invocation -> payeeProfile);
        Mockito.when(clerkPayeeProfileService.updateById(Mockito.any(ClerkPayeeProfileEntity.class)))
                .thenAnswer(invocation -> {
                    payeeProfile = invocation.getArgument(0);
                    return true;
                });
        Mockito.when(clerkPayeeProfileService.save(Mockito.any(ClerkPayeeProfileEntity.class)))
                .thenReturn(true);
        Mockito.when(clerkPayeeProfileService.removeById(Mockito.any()))
                .thenReturn(true);
        clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
        clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
    }

    @AfterEach
    void tearDown() {
        ensureTenantContext();
        if (!earningsToCleanup.isEmpty()) {
            earningsService.removeByIds(earningsToCleanup);
            earningsToCleanup.clear();
        }
        if (!withdrawalsToCleanup.isEmpty()) {
            withdrawalService.removeByIds(withdrawalsToCleanup);
            withdrawalsToCleanup.clear();
        }
        Mockito.reset(clerkPayeeProfileService);
        CustomSecurityContextHolder.remove();
    }

    @Test
    void balanceEndpointAggregatesAvailableAndPendingEarnings() throws Exception {
        ensureTenantContext();
        LocalDateTime now = LocalDateTime.now().withNano(0);
        String availableId = insertEarningsLine(
                "available",
                new BigDecimal("35.50"),
                EarningsStatus.AVAILABLE,
                now.minusHours(1));
        String frozenId = insertEarningsLine(
                "frozen",
                new BigDecimal("64.40"),
                EarningsStatus.FROZEN,
                now.plusHours(6));
        earningsToCleanup.add(availableId);
        earningsToCleanup.add(frozenId);

        MvcResult result = mockMvc.perform(get("/wx/withdraw/balance")
                        .header(USER_HEADER, DEFAULT_USER)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200))
                .andReturn();

        JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
        JsonNode data = root.get("data");
        assertThat(data.get("available").decimalValue()).isEqualByComparingTo("35.50");
        assertThat(data.get("pending").decimalValue()).isEqualByComparingTo("64.40");
        assertThat(data.get("nextUnlockAt").asText())
                .isEqualTo(now.plusHours(6).format(DATE_TIME_FORMATTER));
    }

    @Test
    void createWithdrawRejectsNonPositiveAmount() throws Exception {
        ensureTenantContext();
        mockMvc.perform(post("/wx/withdraw/requests")
                        .header(USER_HEADER, DEFAULT_USER)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"amount\":0}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(500))
                .andExpect(jsonPath("$.message").value("提现金额必须大于0"));
    }

    @Test
    void createWithdrawLocksEligibleEarningsLines() throws Exception {
        ensureTenantContext();
        BigDecimal amount = new BigDecimal("80.00");
        String firstLine = insertEarningsLine(
                "available-one",
                new BigDecimal("50.00"),
                EarningsStatus.AVAILABLE,
                LocalDateTime.now().minusDays(1));
        String secondLine = insertEarningsLine(
                "available-two",
                new BigDecimal("30.00"),
                EarningsStatus.AVAILABLE,
                LocalDateTime.now().minusHours(2));
        earningsToCleanup.add(firstLine);
        earningsToCleanup.add(secondLine);

        MvcResult result = mockMvc.perform(post("/wx/withdraw/requests")
                        .header(USER_HEADER, DEFAULT_USER)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"amount\":80}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200))
                .andExpect(jsonPath("$.data.id").isNotEmpty())
                .andExpect(jsonPath("$.data.amount").value(amount.doubleValue()))
                .andReturn();

        JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
        String withdrawalId = root.path("data").path("id").asText();
        assertThat(withdrawalId).isNotBlank();

        withdrawalsToCleanup.add(withdrawalId);

        ensureTenantContext();
        WithdrawalRequestEntity request = withdrawalService.getById(withdrawalId);
        assertThat(request).isNotNull();
        assertThat(request.getClerkId()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID);
        assertThat(request.getAmount()).isEqualByComparingTo(amount);

        EarningsLineEntity lockedOne = earningsService.getById(firstLine);
        EarningsLineEntity lockedTwo = earningsService.getById(secondLine);
        assertThat(lockedOne.getStatus()).isEqualTo(EarningsStatus.WITHDRAWING.getCode());
        assertThat(lockedTwo.getStatus()).isEqualTo(EarningsStatus.WITHDRAWING.getCode());
        assertThat(lockedOne.getWithdrawalId()).isEqualTo(withdrawalId);
        assertThat(lockedTwo.getWithdrawalId()).isEqualTo(withdrawalId);
    }

    @Test
    void createWithdrawHandlesMixedPositiveAndNegativeLines() throws Exception {
        ensureTenantContext();
        LocalDateTime base = LocalDateTime.now().minusHours(4);
        BigDecimal[] amounts = {
                new BigDecimal("-30"),
                new BigDecimal("20"),
                new BigDecimal("50"),
                new BigDecimal("-10"),
                new BigDecimal("40"),
                new BigDecimal("60"),
                new BigDecimal("15"),
                new BigDecimal("25"),
                new BigDecimal("-5"),
                new BigDecimal("100")};
        String[] lineIds = new String[amounts.length];
        for (int i = 0; i < amounts.length; i++) {
            BigDecimal amount = amounts[i];
            EarningsType type = amount.compareTo(BigDecimal.ZERO) < 0 ? EarningsType.ADJUSTMENT : EarningsType.ORDER;
            String id = insertEarningsLine(
                    "mix-" + i,
                    amount,
                    EarningsStatus.AVAILABLE,
                    base.plusMinutes(i),
                    type);
            lineIds[i] = id;
            earningsToCleanup.add(id);
        }

        refreshPayeeConfirmation();
        String firstWithdrawal = createWithdraw(new BigDecimal("35"));
        assertLinesLocked(firstWithdrawal, lineIds[0], lineIds[1], lineIds[2]);

        refreshPayeeConfirmation();
        String secondWithdrawal = createWithdraw(new BigDecimal("90"));
        assertLinesLocked(secondWithdrawal, lineIds[3], lineIds[4], lineIds[5]);

        refreshPayeeConfirmation();
        String thirdWithdrawal = createWithdraw(new BigDecimal("135"));
        assertLinesLocked(thirdWithdrawal, lineIds[6], lineIds[7], lineIds[8], lineIds[9]);

        ensureTenantContext();
        BigDecimal remaining = earningsService.getAvailableAmount(ApiTestDataSeeder.DEFAULT_CLERK_ID, LocalDateTime.now());
        assertThat(remaining).isEqualByComparingTo(BigDecimal.ZERO);
    }

    @Test
    void earningsEndpointFiltersByStatus() throws Exception {
        ensureTenantContext();
        String availableId = insertEarningsLine(
                "earning-available",
                new BigDecimal("20.00"),
                EarningsStatus.AVAILABLE,
                LocalDateTime.now().minusHours(3));
        String frozenId = insertEarningsLine(
                "earning-frozen",
                new BigDecimal("45.00"),
                EarningsStatus.FROZEN,
                LocalDateTime.now().plusHours(6));
        String withdrawnId = insertEarningsLine(
                "earning-withdrawn",
                new BigDecimal("15.00"),
                EarningsStatus.WITHDRAWING,
                LocalDateTime.now().minusDays(1));
        earningsToCleanup.add(availableId);
        earningsToCleanup.add(frozenId);
        earningsToCleanup.add(withdrawnId);

        mockMvc.perform(get("/wx/withdraw/earnings")
                        .param("status", EarningsStatus.AVAILABLE.getCode())
                        .param("pageNum", "1")
                        .param("pageSize", "10")
                        .header(USER_HEADER, DEFAULT_USER)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200))
                .andExpect(jsonPath("$.data").isArray())
                .andExpect(jsonPath("$.data[0].id").value(availableId))
                .andExpect(jsonPath("$.data[0].status").value(EarningsStatus.AVAILABLE.getCode()))
                .andExpect(jsonPath("$.data[1]").doesNotExist());
    }

    private String insertEarningsLine(
            String suffix, BigDecimal amount, EarningsStatus status, LocalDateTime unlockAt) {
        return insertEarningsLine(suffix, amount, status, unlockAt, EarningsType.ORDER);
    }

    private String insertEarningsLine(
            String suffix,
            BigDecimal amount,
            EarningsStatus status,
            LocalDateTime unlockAt,
            EarningsType earningType) {
        EarningsLineEntity entity = new EarningsLineEntity();
        String id = "earn-" + suffix + "-" + IdUtils.getUuid();
        entity.setId(id);
        entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
        entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
        String rawOrderId = ORDER_ID_PREFIX + IdUtils.getUuid();
        entity.setOrderId(rawOrderId.length() <= 32 ? rawOrderId : rawOrderId.substring(0, 32));
        entity.setAmount(amount);
        entity.setStatus(status.getCode());
        entity.setUnlockTime(unlockAt);
        entity.setEarningType(earningType);
        Date now = toDate(LocalDateTime.now());
        entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
        entity.setCreatedTime(now);
        entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
        entity.setUpdatedTime(now);
        earningsService.save(entity);
        return id;
    }

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

    private void refreshPayeeConfirmation() {
        if (payeeProfile != null) {
            payeeProfile.setLastConfirmedAt(LocalDateTime.now());
        }
    }

    private String createWithdraw(BigDecimal amount) throws Exception {
        MvcResult result = mockMvc.perform(post("/wx/withdraw/requests")
                        .header(USER_HEADER, DEFAULT_USER)
                        .header(TENANT_HEADER, DEFAULT_TENANT)
                        .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"amount\":" + amount.toPlainString() + "}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200))
                .andReturn();

        JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
        String withdrawalId = root.path("data").path("id").asText();
        assertThat(withdrawalId).isNotBlank();
        withdrawalsToCleanup.add(withdrawalId);
        return withdrawalId;
    }

    private void assertLinesLocked(String withdrawalId, String... lineIds) {
        ensureTenantContext();
        for (String id : lineIds) {
            EarningsLineEntity line = earningsService.getById(id);
            assertThat(line.getStatus()).isEqualTo(EarningsStatus.WITHDRAWING.getCode());
            assertThat(line.getWithdrawalId()).isEqualTo(withdrawalId);
        }
    }

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

    private enum EarningsStatus {
        AVAILABLE("available"),
        FROZEN("frozen"),
        WITHDRAWING("withdrawing");

        private final String code;

        EarningsStatus(String code) {
            this.code = code;
        }

        String getCode() {
            return code;
        }
    }
}
