package com.ruoyi.generator.service.front;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.generator.domain.front.FrontProject;
import com.ruoyi.generator.domain.front.FrontProjectColumn;
import com.ruoyi.generator.domain.front.FrontProjectPageDesign;
import com.ruoyi.generator.domain.front.FrontProjectTable;
import com.ruoyi.generator.domain.front.dto.FrontendPageDesignInitResponse;
import com.ruoyi.generator.domain.front.dto.FrontendPageDesignRequest;
import com.ruoyi.generator.domain.front.dto.FrontendPageDesignResponse;
import com.ruoyi.generator.mapper.front.FrontProjectColumnMapper;
import com.ruoyi.generator.mapper.front.FrontProjectPageDesignMapper;
import com.ruoyi.generator.mapper.front.FrontProjectTableMapper;

public class FrontendPageDesignServiceTest
{
    private FrontendPageDesignService service;

    @Mock
    private IFrontProjectService frontProjectService;
    @Mock
    private FrontProjectPageDesignMapper pageDesignMapper;
    @Mock
    private FrontProjectTableMapper frontProjectTableMapper;
    @Mock
    private FrontProjectColumnMapper frontProjectColumnMapper;

    @Before
    public void setUp() throws Exception
    {
        MockitoAnnotations.initMocks(this);
        service = new FrontendPageDesignService();
        setField("frontProjectService", frontProjectService);
        setField("pageDesignMapper", pageDesignMapper);
        setField("frontProjectTableMapper", frontProjectTableMapper);
        setField("frontProjectColumnMapper", frontProjectColumnMapper);
        setField("businessBlockRegistryService", new BusinessBlockRegistryService());
    }

    @Test
    public void initializeCreatesMissingDesignFromFrontendPages()
    {
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectDesignsByProjectIdAndScope(10L, "frontend"))
                .thenReturn(Collections.<FrontProjectPageDesign>emptyList());
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.singletonList(bookTable()));
        when(frontProjectColumnMapper.selectColumnsByTableId(99L)).thenReturn(Arrays.asList(column("title", "title", "1", "1", "1"),
                column("status", "status", "1", "1", "1")));

        FrontendPageDesignInitResponse response = service.initializePageDesigns(7L, 10L);

        List<FrontendPageDesignResponse> designs = response.getDesigns();
        assertEquals(2, designs.size());
        assertEquals("home", designs.get(0).getPageCode());
        assertEquals("book_catalog_page", designs.get(1).getPageCode());
        assertTrue(designs.get(1).getLayoutJson().contains("\"type\":\"query\""));
        assertTrue(designs.get(1).getLayoutJson().contains("\"title\""));
        verify(pageDesignMapper, times(2)).insertFrontProjectPageDesign(any(FrontProjectPageDesign.class));
    }

    @Test
    public void initializeCreatesFrontendHomePageBeforeBlueprintPages()
    {
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectDesignsByProjectIdAndScope(10L, "frontend"))
                .thenReturn(Collections.<FrontProjectPageDesign>emptyList());
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.singletonList(bookTable()));
        when(frontProjectColumnMapper.selectColumnsByTableId(99L)).thenReturn(Arrays.asList(
                column("title", "title", "1", "1", "1"),
                column("status", "status", "1", "1", "1")));

        FrontendPageDesignInitResponse response = service.initializePageDesigns(7L, 10L, "frontend");

        List<FrontendPageDesignResponse> designs = response.getDesigns();
        assertEquals(2, designs.size());
        assertEquals("home", designs.get(0).getPageCode());
        assertEquals("\u4e3b\u9875", designs.get(0).getPageName());
        assertEquals("/home", designs.get(0).getRoutePath());
        assertEquals("business_block", designs.get(0).getPageType());
        assertEquals("frontend", designs.get(0).getPageScope());
        assertTrue(designs.get(0).getLayoutJson().contains("\"menuOrder\":1"));
        assertEquals("book_catalog_page", designs.get(1).getPageCode());
    }

    @Test
    public void initializeCopiesParentPageCodeToHiddenDetailNavigation()
    {
        when(frontProjectService.getProject(7L, 10L)).thenReturn(projectWithAssociatedDetailPage());
        when(pageDesignMapper.selectDesignsByProjectIdAndScope(10L, "frontend"))
                .thenReturn(Collections.<FrontProjectPageDesign>emptyList());
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.singletonList(bookTable()));
        when(frontProjectColumnMapper.selectColumnsByTableId(99L)).thenReturn(Arrays.asList(
                column("id", "id", "1", "1", "1"),
                column("book_title", "bookTitle", "1", "1", "1")));

        service.initializePageDesigns(7L, 10L, "frontend");

        ArgumentCaptor<FrontProjectPageDesign> captor = ArgumentCaptor.forClass(FrontProjectPageDesign.class);
        verify(pageDesignMapper, times(3)).insertFrontProjectPageDesign(captor.capture());
        FrontProjectPageDesign detail = captor.getAllValues().get(2);
        assertEquals("book_detail_page", detail.getPageCode());
        assertTrue(detail.getLayoutJson().contains("\"parentPageCode\":\"book_catalog_page\""));
        assertTrue(detail.getLayoutJson().contains("\"showInMenu\":false"));
    }

    @Test
    public void initializeFrontendDesignsSkipsAdminBlueprintPages()
    {
        when(frontProjectService.getProject(7L, 10L)).thenReturn(projectWithMixedFrontendPages());
        when(pageDesignMapper.selectDesignsByProjectIdAndScope(10L, "frontend"))
                .thenReturn(Collections.<FrontProjectPageDesign>emptyList());
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.singletonList(bookTable()));
        when(frontProjectColumnMapper.selectColumnsByTableId(99L)).thenReturn(Arrays.asList(
                column("title", "title", "1", "1", "1"),
                column("status", "status", "1", "1", "1")));

        FrontendPageDesignInitResponse response = service.initializePageDesigns(7L, 10L, "frontend");

        List<FrontendPageDesignResponse> designs = response.getDesigns();
        assertEquals(2, designs.size());
        assertEquals("home", designs.get(0).getPageCode());
        assertEquals("book_catalog_page", designs.get(1).getPageCode());
        assertEquals("/books", designs.get(1).getRoutePath());
        ArgumentCaptor<FrontProjectPageDesign> captor = ArgumentCaptor.forClass(FrontProjectPageDesign.class);
        verify(pageDesignMapper, times(2)).insertFrontProjectPageDesign(captor.capture());
        assertEquals("home", captor.getAllValues().get(0).getPageCode());
        assertEquals("book_catalog_page", captor.getAllValues().get(1).getPageCode());
    }

    @Test
    public void listFrontendDesignsExcludesAdminRouteLeaks()
    {
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        FrontProjectPageDesign catalog = currentDesign();
        FrontProjectPageDesign leakedAdmin = currentDesign();
        leakedAdmin.setDesignId(89L);
        leakedAdmin.setPageCode("book_admin");
        leakedAdmin.setPageName("Book Admin");
        leakedAdmin.setRoutePath("/admin/books");
        leakedAdmin.setLayoutJson("{\"canvas\":\"admin-list-v1\",\"navigation\":{\"showInMenu\":true}}");
        when(pageDesignMapper.selectDesignsByProjectIdAndScope(10L, "frontend"))
                .thenReturn(Arrays.asList(catalog, leakedAdmin));

        List<FrontendPageDesignResponse> designs = service.listPageDesigns(7L, 10L, "frontend");

        assertEquals(1, designs.size());
        assertEquals("book_catalog_page", designs.get(0).getPageCode());
    }

    @Test
    public void initializeAdminDesignsMergesAdminMenusAndTables()
    {
        when(frontProjectService.getProject(7L, 10L)).thenReturn(projectWithAdminMenu());
        when(pageDesignMapper.selectDesignsByProjectId(10L)).thenReturn(Collections.<FrontProjectPageDesign>emptyList());
        FrontProjectTable bookTable = bookTable();
        bookTable.setAdminMenuName("Book Admin");
        FrontProjectTable customerTable = customerTable();
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Arrays.asList(bookTable, customerTable));
        when(frontProjectColumnMapper.selectColumnsByTableId(99L)).thenReturn(Arrays.asList(column("title", "title", "1", "1", "1")));
        when(frontProjectColumnMapper.selectColumnsByTableId(100L)).thenReturn(Arrays.asList(column("customer_name", "customerName", "1", "1", "1")));

        FrontendPageDesignInitResponse response = service.initializePageDesigns(7L, 10L, "admin");

        List<FrontendPageDesignResponse> designs = response.getDesigns();
        assertEquals(3, designs.size());
        assertEquals("admin", designs.get(0).getPageScope());
        assertEquals("admin_home", designs.get(0).getPageCode());
        assertEquals("\u4e3b\u9875", designs.get(0).getPageName());
        assertEquals("/admin/home", designs.get(0).getRoutePath());
        assertEquals("business_block", designs.get(0).getPageType());
        assertTrue(designs.get(0).getLayoutJson().contains("\"menuOrder\":1"));
        assertEquals("book_admin", designs.get(1).getPageCode());
        assertEquals("book_info", designs.get(1).getTableName());
        assertEquals("admin_customer_info", designs.get(2).getPageCode());
        assertEquals("customer_info", designs.get(2).getTableName());
        assertTrue(designs.get(2).getLayoutJson().contains("\"canvas\":\"admin-list-v1\""));
    }

    @Test
    public void createPageDesignPersistsNewFrontendPage()
    {
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectDesignsByProjectIdAndScope(10L, "frontend"))
                .thenReturn(Collections.<FrontProjectPageDesign>emptyList());
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.singletonList(bookTable()));
        when(frontProjectColumnMapper.selectColumnsByTableId(99L)).thenReturn(Arrays.asList(
                column("book_title", "bookTitle", "1", "1", "1"),
                column("author", "author", "0", "1", "1")));
        FrontendPageDesignRequest request = new FrontendPageDesignRequest();
        request.setPageCode("custom_books");
        request.setPageName("Custom Books");
        request.setRoutePath("/custom-books");
        request.setPageType("list");
        request.setTableName("book_info");

        service.createPageDesign(7L, 10L, request, "frontend");

        ArgumentCaptor<FrontProjectPageDesign> captor = ArgumentCaptor.forClass(FrontProjectPageDesign.class);
        verify(pageDesignMapper).insertFrontProjectPageDesign(captor.capture());
        assertEquals("frontend", captor.getValue().getPageScope());
        assertEquals("custom_books", captor.getValue().getPageCode());
        assertEquals("custom_books", captor.getValue().getMenuCode());
        assertEquals(Integer.valueOf(1), captor.getValue().getVersion());
        assertTrue(captor.getValue().getLayoutJson().contains("\"canvas\":\"frontend-list-v1\""));
        assertTrue(captor.getValue().getLayoutJson().contains("bookTitle"));
    }

    @Test
    public void createPageDesignPersistsMenuDirectoryWithoutRouteOrTable()
    {
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectDesignsByProjectIdAndScope(10L, "frontend"))
                .thenReturn(Collections.<FrontProjectPageDesign>emptyList());
        FrontendPageDesignRequest request = new FrontendPageDesignRequest();
        request.setPageCode("library_directory");
        request.setMenuCode("library_directory");
        request.setPageName("Library");
        request.setPageType("menu_directory");
        request.setLayoutJson("{\"navigation\":{\"showInMenu\":true,\"menuLevel\":\"primary\",\"nodeType\":\"directory\",\"menuCode\":\"library_directory\",\"menuName\":\"Library\"}}");

        service.createPageDesign(7L, 10L, request, "frontend");

        ArgumentCaptor<FrontProjectPageDesign> captor = ArgumentCaptor.forClass(FrontProjectPageDesign.class);
        verify(pageDesignMapper).insertFrontProjectPageDesign(captor.capture());
        assertEquals("menu_directory", captor.getValue().getPageType());
        assertEquals("", captor.getValue().getRoutePath());
        assertEquals("", captor.getValue().getTableName());
        assertTrue(captor.getValue().getLayoutJson().contains("\"nodeType\":\"directory\""));
        assertTrue(captor.getValue().getLayoutJson().contains("\"menuLevel\":\"primary\""));
    }

    @Test
    public void deletePageDesignRemovesOwnedDesign()
    {
        FrontProjectPageDesign current = currentDesign();
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectFrontProjectPageDesignById(88L)).thenReturn(current);

        service.deletePageDesign(7L, 10L, 88L, "frontend");

        verify(pageDesignMapper).deleteFrontProjectPageDesignById(88L);
    }

    @Test
    public void updateRejectsUnknownLayoutField()
    {
        FrontProjectPageDesign current = currentDesign();
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectFrontProjectPageDesignById(88L)).thenReturn(current);
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.singletonList(bookTable()));
        when(frontProjectColumnMapper.selectColumnsByTableId(99L)).thenReturn(Collections.singletonList(column("title", "title", "1", "1", "1")));
        FrontendPageDesignRequest request = new FrontendPageDesignRequest();
        request.setVersion(1);
        request.setLayoutJson("{\"regions\":[{\"id\":\"table\",\"type\":\"table\",\"fields\":[\"missingField\"]}]}");
        request.setActionJson("{\"toolbarActions\":[],\"rowActions\":[]}");

        ServiceException exception = expectServiceException(new ThrowingRunnable()
        {
            @Override
            public void run()
            {
                service.updatePageDesign(7L, 10L, 88L, request);
            }
        });

        assertTrue(exception.getMessage().contains("missingField"));
    }

    @Test
    public void updatePersistsValidLayoutAndIncrementsVersion()
    {
        FrontProjectPageDesign current = currentDesign();
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectFrontProjectPageDesignById(88L)).thenReturn(current);
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.singletonList(bookTable()));
        when(frontProjectColumnMapper.selectColumnsByTableId(99L)).thenReturn(Arrays.asList(column("book_title", "bookTitle", "1", "1", "1")));
        FrontendPageDesignRequest request = new FrontendPageDesignRequest();
        request.setVersion(1);
        request.setPageName("Books");
        request.setRoutePath("/books");
        request.setPageType("list");
        request.setTableName("book_info");
        request.setLayoutJson("{\"regions\":[{\"id\":\"table\",\"type\":\"table\",\"fields\":[\"book_title\"]}]}");
        request.setActionJson("{\"toolbarActions\":[{\"code\":\"create\"}],\"rowActions\":[{\"code\":\"view\"}]}");

        service.updatePageDesign(7L, 10L, 88L, request);

        ArgumentCaptor<FrontProjectPageDesign> captor = ArgumentCaptor.forClass(FrontProjectPageDesign.class);
        verify(pageDesignMapper).updateFrontProjectPageDesign(captor.capture());
        assertEquals(Long.valueOf(88L), captor.getValue().getDesignId());
        assertEquals(Integer.valueOf(2), captor.getValue().getVersion());
        assertTrue(captor.getValue().getLayoutJson().contains("bookTitle"));
    }

    @Test
    public void updateRejectsDisabledBuiltinLayoutAction()
    {
        FrontProjectPageDesign current = currentDesign();
        FrontProjectTable table = bookTable();
        table.setAllowRemove("0");
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectFrontProjectPageDesignById(88L)).thenReturn(current);
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.singletonList(table));
        when(frontProjectColumnMapper.selectColumnsByTableId(99L)).thenReturn(Collections.singletonList(column("book_title", "bookTitle", "1", "1", "1")));
        FrontendPageDesignRequest request = new FrontendPageDesignRequest();
        request.setVersion(1);
        request.setPageType("list");
        request.setTableName("book_info");
        request.setLayoutJson("{\"regions\":[{\"id\":\"table\",\"type\":\"table\",\"fields\":[\"book_title\"],\"rowActions\":[\"delete\"]}]}");
        request.setActionJson("{\"toolbarActions\":[],\"rowActions\":[]}");

        ServiceException exception = expectServiceException(new ThrowingRunnable()
        {
            @Override
            public void run()
            {
                service.updatePageDesign(7L, 10L, 88L, request);
            }
        });

        assertTrue(exception.getMessage().contains("delete"));
    }

    @Test
    public void updateRejectsDisabledBuiltinActionJson()
    {
        FrontProjectPageDesign current = currentDesign();
        FrontProjectTable table = bookTable();
        table.setAllowAdd("0");
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectFrontProjectPageDesignById(88L)).thenReturn(current);
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.singletonList(table));
        when(frontProjectColumnMapper.selectColumnsByTableId(99L)).thenReturn(Collections.singletonList(column("book_title", "bookTitle", "1", "1", "1")));
        FrontendPageDesignRequest request = new FrontendPageDesignRequest();
        request.setVersion(1);
        request.setPageType("list");
        request.setTableName("book_info");
        request.setLayoutJson("{\"regions\":[{\"id\":\"table\",\"type\":\"table\",\"fields\":[\"book_title\"],\"rowActions\":[]}]}");
        request.setActionJson("{\"toolbarActions\":[{\"code\":\"create\"}],\"rowActions\":[]}");

        ServiceException exception = expectServiceException(new ThrowingRunnable()
        {
            @Override
            public void run()
            {
                service.updatePageDesign(7L, 10L, 88L, request);
            }
        });

        assertTrue(exception.getMessage().contains("create"));
    }

    @Test
    public void updateRejectsLayoutCanvasOutsidePageType()
    {
        FrontProjectPageDesign current = currentDesign();
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectFrontProjectPageDesignById(88L)).thenReturn(current);
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.singletonList(bookTable()));
        when(frontProjectColumnMapper.selectColumnsByTableId(99L)).thenReturn(Collections.singletonList(column("book_title", "bookTitle", "1", "1", "1")));
        FrontendPageDesignRequest request = new FrontendPageDesignRequest();
        request.setVersion(1);
        request.setPageType("list");
        request.setLayoutJson("{\"canvas\":\"frontend-form-group-v1\",\"regions\":[{\"id\":\"table\",\"type\":\"table\",\"fields\":[\"bookTitle\"]}]}");
        request.setActionJson("{\"toolbarActions\":[],\"rowActions\":[]}");

        ServiceException exception = expectServiceException(new ThrowingRunnable()
        {
            @Override
            public void run()
            {
                service.updatePageDesign(7L, 10L, 88L, request);
            }
        });

        assertTrue(exception.getMessage().contains("布局"));
    }

    @Test
    public void updatePersistsLayoutCanvasForMatchingPageType()
    {
        FrontProjectPageDesign current = currentDesign();
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectFrontProjectPageDesignById(88L)).thenReturn(current);
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.singletonList(bookTable()));
        when(frontProjectColumnMapper.selectColumnsByTableId(99L)).thenReturn(Collections.singletonList(column("book_title", "bookTitle", "1", "1", "1")));
        FrontendPageDesignRequest request = new FrontendPageDesignRequest();
        request.setVersion(1);
        request.setPageType("list");
        request.setLayoutJson("{\"canvas\":\"frontend-list-card-v1\",\"regions\":[{\"id\":\"table\",\"type\":\"table\",\"fields\":[\"book_title\"]}]}");
        request.setActionJson("{\"toolbarActions\":[],\"rowActions\":[]}");

        service.updatePageDesign(7L, 10L, 88L, request);

        ArgumentCaptor<FrontProjectPageDesign> captor = ArgumentCaptor.forClass(FrontProjectPageDesign.class);
        verify(pageDesignMapper).updateFrontProjectPageDesign(captor.capture());
        assertTrue(captor.getValue().getLayoutJson().contains("\"canvas\":\"frontend-list-card-v1\""));
        assertTrue(captor.getValue().getLayoutJson().contains("bookTitle"));
    }

    @Test
    public void updateNormalizesCardRegionFieldsAndImageField()
    {
        FrontProjectPageDesign current = currentDesign();
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectFrontProjectPageDesignById(88L)).thenReturn(current);
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.singletonList(bookTable()));
        when(frontProjectColumnMapper.selectColumnsByTableId(99L)).thenReturn(Arrays.asList(
                column("book_title", "bookTitle", "1", "1", "1"),
                column("cover_url", "coverUrl", "0", "1", "1")));
        FrontendPageDesignRequest request = new FrontendPageDesignRequest();
        request.setVersion(1);
        request.setPageType("list");
        request.setLayoutJson("{\"canvas\":\"frontend-list-card-v1\",\"regions\":["
                + "{\"id\":\"query\",\"type\":\"query\",\"visible\":false,\"fields\":[\"book_title\"]},"
                + "{\"id\":\"card\",\"type\":\"cardList\",\"titleField\":\"book_title\",\"imageField\":\"cover_url\",\"fields\":[\"book_title\"]},"
                + "{\"id\":\"cardActions\",\"type\":\"actions\",\"actions\":[\"edit\"]}"
                + "]}");
        request.setActionJson("{\"toolbarActions\":[],\"rowActions\":[{\"code\":\"edit\"}]}");

        service.updatePageDesign(7L, 10L, 88L, request);

        ArgumentCaptor<FrontProjectPageDesign> captor = ArgumentCaptor.forClass(FrontProjectPageDesign.class);
        verify(pageDesignMapper).updateFrontProjectPageDesign(captor.capture());
        assertTrue(captor.getValue().getLayoutJson().contains("\"titleField\":\"bookTitle\""));
        assertTrue(captor.getValue().getLayoutJson().contains("\"imageField\":\"coverUrl\""));
        assertTrue(captor.getValue().getLayoutJson().contains("\"visible\":false"));
    }

    @Test
    public void updatePersistsValidBusinessBlockLayout()
    {
        FrontProjectPageDesign current = currentBusinessBlockDesign();
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectFrontProjectPageDesignById(88L)).thenReturn(current);
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Arrays.asList(cartTable(), productTable()));
        when(frontProjectColumnMapper.selectColumnsByTableId(201L)).thenReturn(Arrays.asList(
                column("id", "id", "0", "1", "1"),
                column("product_id", "productId", "0", "1", "1"),
                column("quantity", "quantity", "0", "1", "1"),
                column("user_id", "userId", "0", "1", "1")));
        when(frontProjectColumnMapper.selectColumnsByTableId(202L)).thenReturn(Arrays.asList(
                column("id", "id", "0", "1", "1"),
                column("name", "name", "0", "1", "1"),
                column("price", "price", "0", "1", "1"),
                column("stock", "stock", "0", "1", "1")));
        FrontendPageDesignRequest request = businessBlockRequest(validBusinessBlockLayout());

        service.updatePageDesign(7L, 10L, 88L, request);

        ArgumentCaptor<FrontProjectPageDesign> captor = ArgumentCaptor.forClass(FrontProjectPageDesign.class);
        verify(pageDesignMapper).updateFrontProjectPageDesign(captor.capture());
        assertTrue(captor.getValue().getLayoutJson().contains("\"canvas\":\"business-blocks-v1\""));
        assertTrue(captor.getValue().getLayoutJson().contains("\"blockCode\":\"cart\""));
    }

    @Test
    public void updatePersistsAdminHomeBusinessBlockWithoutTable()
    {
        FrontProjectPageDesign current = currentAdminHomeDesign();
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectFrontProjectPageDesignById(88L)).thenReturn(current);
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.<FrontProjectTable>emptyList());
        FrontendPageDesignRequest request = new FrontendPageDesignRequest();
        request.setVersion(1);
        request.setPageName("\u4e3b\u9875");
        request.setRoutePath("/admin/home");
        request.setPageType("business_block");
        request.setLayoutJson("{\"canvas\":\"business-blocks-v1\",\"blocks\":[],\"navigation\":{\"showInMenu\":true,\"menuOrder\":1}}");
        request.setActionJson("{\"toolbarActions\":[],\"rowActions\":[]}");

        service.updatePageDesign(7L, 10L, 88L, request, "admin");

        ArgumentCaptor<FrontProjectPageDesign> captor = ArgumentCaptor.forClass(FrontProjectPageDesign.class);
        verify(pageDesignMapper).updateFrontProjectPageDesign(captor.capture());
        assertEquals("admin", captor.getValue().getPageScope());
        assertEquals("", captor.getValue().getTableName());
        assertTrue(captor.getValue().getLayoutJson().contains("\"canvas\":\"business-blocks-v1\""));
    }

    @Test
    public void updatePersistsValidAdminChartLayout()
    {
        FrontProjectPageDesign current = currentAdminHomeDesign();
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectFrontProjectPageDesignById(88L)).thenReturn(current);
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.singletonList(orderTable()));
        when(frontProjectColumnMapper.selectColumnsByTableId(203L)).thenReturn(Arrays.asList(
                typedColumn("id", "id", "Long", "bigint"),
                typedColumn("amount", "amount", "BigDecimal", "decimal(10,2)"),
                typedColumn("status", "status", "String", "varchar(32)"),
                typedColumn("create_time", "createTime", "Date", "datetime")));
        FrontendPageDesignRequest request = adminChartRequest(validAdminChartLayout());

        service.updatePageDesign(7L, 10L, 88L, request, "admin");

        ArgumentCaptor<FrontProjectPageDesign> captor = ArgumentCaptor.forClass(FrontProjectPageDesign.class);
        verify(pageDesignMapper).updateFrontProjectPageDesign(captor.capture());
        assertTrue(captor.getValue().getLayoutJson().contains("\"blockCode\":\"admin_line_chart\""));
        assertTrue(captor.getValue().getLayoutJson().contains("\"table\":\"shop_order\""));
    }

    @Test
    public void updateRejectsAdminChartOnFrontendPage()
    {
        FrontProjectPageDesign current = currentBusinessBlockDesign();
        current.setTableName("shop_order");
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectFrontProjectPageDesignById(88L)).thenReturn(current);
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Collections.singletonList(orderTable()));
        when(frontProjectColumnMapper.selectColumnsByTableId(203L)).thenReturn(Arrays.asList(
                typedColumn("id", "id", "Long", "bigint"),
                typedColumn("amount", "amount", "BigDecimal", "decimal(10,2)"),
                typedColumn("status", "status", "String", "varchar(32)"),
                typedColumn("create_time", "createTime", "Date", "datetime")));
        FrontendPageDesignRequest request = adminChartRequest(validAdminChartLayout());

        ServiceException exception = expectServiceException(() ->
                service.updatePageDesign(7L, 10L, 88L, request, "frontend"));

        assertTrue(exception.getMessage().contains("后台"));
    }

    @Test
    public void updateRejectsBusinessBlockMissingRequiredConfig()
    {
        ServiceException exception = expectBusinessBlockUpdateFailure("{\"canvas\":\"business-blocks-v1\",\"blocks\":[{\"id\":\"cart_001\",\"blockCode\":\"cart\",\"config\":{\"productTable\":\"shop_product\"}}]}");

        assertTrue(exception.getMessage().contains("cartTable"));
    }

    @Test
    public void updateRejectsBusinessBlockUnknownTable()
    {
        String layout = validBusinessBlockLayout().replace("\"shop_cart\"", "\"missing_cart\"");

        ServiceException exception = expectBusinessBlockUpdateFailure(layout);

        assertTrue(exception.getMessage().contains("missing_cart"));
    }

    @Test
    public void updateRejectsBusinessBlockUnknownField()
    {
        String layout = validBusinessBlockLayout().replace("\"name\"", "\"missing_name\"");

        ServiceException exception = expectBusinessBlockUpdateFailure(layout);

        assertTrue(exception.getMessage().contains("missing_name"));
    }

    private void setField(String name, Object value) throws Exception
    {
        Field field = FrontendPageDesignService.class.getDeclaredField(name);
        field.setAccessible(true);
        field.set(service, value);
    }

    private FrontProject project()
    {
        FrontProject project = new FrontProject();
        project.setProjectId(10L);
        project.setUserId(7L);
        project.setAppBlueprint("{\"frontendPages\":[{\"code\":\"book_catalog_page\",\"name\":\"Book Catalog\",\"menuCode\":\"book_catalog\",\"path\":\"/books\",\"pageType\":\"list\",\"tableName\":\"book_info\"}]}");
        return project;
    }

    private FrontProject projectWithAdminMenu()
    {
        FrontProject project = new FrontProject();
        project.setProjectId(10L);
        project.setUserId(7L);
        project.setAppBlueprint("{\"adminMenus\":[{\"code\":\"book_admin\",\"name\":\"Book Admin\",\"path\":\"/admin/books\",\"requiresLogin\":true,\"visibleRoles\":[\"admin\"],\"dataScope\":\"ROLE\"}]}");
        return project;
    }

    private FrontProject projectWithMixedFrontendPages()
    {
        FrontProject project = new FrontProject();
        project.setProjectId(10L);
        project.setUserId(7L);
        project.setAppBlueprint("{\"frontendPages\":["
                + "{\"code\":\"book_catalog_page\",\"name\":\"Book Catalog\",\"menuCode\":\"book_catalog\",\"path\":\"/books\",\"pageType\":\"list\",\"tableName\":\"book_info\"},"
                + "{\"code\":\"book_admin\",\"name\":\"Book Admin\",\"menuCode\":\"book_admin\",\"path\":\"/admin/books\",\"pageType\":\"list\",\"tableName\":\"book_info\"}"
                + "]}");
        return project;
    }

    private FrontProject projectWithAssociatedDetailPage()
    {
        FrontProject project = new FrontProject();
        project.setProjectId(10L);
        project.setUserId(7L);
        project.setAppBlueprint("{\"frontendPages\":["
                + "{\"code\":\"book_catalog_page\",\"name\":\"Book Catalog\",\"menuCode\":\"book_catalog\",\"path\":\"/books\",\"pageType\":\"list\",\"tableName\":\"book_info\"},"
                + "{\"code\":\"book_detail_page\",\"name\":\"Book Detail\",\"parentPageCode\":\"book_catalog_page\",\"path\":\"/books/:id\",\"pageType\":\"detail\",\"tableName\":\"book_info\"}"
                + "]}");
        return project;
    }

    private FrontProjectTable bookTable()
    {
        FrontProjectTable table = new FrontProjectTable();
        table.setTableId(99L);
        table.setProjectId(10L);
        table.setTableName("book_info");
        table.setAllowAdd("1");
        table.setAllowEdit("1");
        table.setAllowRemove("1");
        return table;
    }

    private FrontProjectTable customerTable()
    {
        FrontProjectTable table = new FrontProjectTable();
        table.setTableId(100L);
        table.setProjectId(10L);
        table.setTableName("customer_info");
        table.setTableComment("Customer info");
        table.setAdminMenuName("Customer Management");
        table.setAllowAdd("1");
        table.setAllowEdit("1");
        table.setAllowRemove("1");
        return table;
    }

    private FrontProjectTable cartTable()
    {
        FrontProjectTable table = new FrontProjectTable();
        table.setTableId(201L);
        table.setProjectId(10L);
        table.setTableName("shop_cart");
        table.setAllowAdd("1");
        table.setAllowEdit("1");
        table.setAllowRemove("1");
        return table;
    }

    private FrontProjectTable productTable()
    {
        FrontProjectTable table = new FrontProjectTable();
        table.setTableId(202L);
        table.setProjectId(10L);
        table.setTableName("shop_product");
        table.setAllowAdd("1");
        table.setAllowEdit("1");
        table.setAllowRemove("1");
        return table;
    }

    private FrontProjectTable orderTable()
    {
        FrontProjectTable table = new FrontProjectTable();
        table.setTableId(203L);
        table.setProjectId(10L);
        table.setTableName("shop_order");
        table.setAllowAdd("1");
        table.setAllowEdit("1");
        table.setAllowRemove("1");
        return table;
    }

    private FrontProjectColumn column(String columnName, String javaField, String isQuery, String isList, String isEdit)
    {
        FrontProjectColumn column = new FrontProjectColumn();
        column.setColumnName(columnName);
        column.setJavaField(javaField);
        column.setIsQuery(isQuery);
        column.setIsList(isList);
        column.setIsEdit(isEdit);
        column.setIsInsert(isEdit);
        return column;
    }

    private FrontProjectColumn typedColumn(String columnName, String javaField, String javaType, String columnType)
    {
        FrontProjectColumn column = column(columnName, javaField, "0", "1", "0");
        column.setJavaType(javaType);
        column.setColumnType(columnType);
        return column;
    }

    private FrontProjectPageDesign currentDesign()
    {
        FrontProjectPageDesign design = new FrontProjectPageDesign();
        design.setDesignId(88L);
        design.setProjectId(10L);
        design.setUserId(7L);
        design.setPageCode("book_catalog_page");
        design.setPageScope("frontend");
        design.setPageName("Book Catalog");
        design.setRoutePath("/books");
        design.setPageType("list");
        design.setTableName("book_info");
        design.setVersion(1);
        return design;
    }

    private FrontProjectPageDesign currentBusinessBlockDesign()
    {
        FrontProjectPageDesign design = currentDesign();
        design.setPageType("business_block");
        design.setTableName("shop_cart");
        design.setLayoutJson("{\"canvas\":\"business-blocks-v1\",\"blocks\":[]}");
        return design;
    }

    private FrontProjectPageDesign currentAdminHomeDesign()
    {
        FrontProjectPageDesign design = currentDesign();
        design.setPageScope("admin");
        design.setPageCode("admin_home");
        design.setMenuCode("admin_home");
        design.setPageName("\u4e3b\u9875");
        design.setRoutePath("/admin/home");
        design.setPageType("business_block");
        design.setTableName("");
        design.setLayoutJson("{\"canvas\":\"business-blocks-v1\",\"blocks\":[],\"navigation\":{\"showInMenu\":true,\"menuOrder\":1}}");
        design.setActionJson("{\"toolbarActions\":[],\"rowActions\":[]}");
        return design;
    }

    private FrontendPageDesignRequest businessBlockRequest(String layoutJson)
    {
        FrontendPageDesignRequest request = new FrontendPageDesignRequest();
        request.setVersion(1);
        request.setPageName("Cart");
        request.setRoutePath("/cart");
        request.setPageType("business_block");
        request.setTableName("shop_cart");
        request.setLayoutJson(layoutJson);
        request.setActionJson("{\"toolbarActions\":[],\"rowActions\":[]}");
        return request;
    }

    private FrontendPageDesignRequest adminChartRequest(String layoutJson)
    {
        FrontendPageDesignRequest request = new FrontendPageDesignRequest();
        request.setVersion(1);
        request.setPageName("\u4e3b\u9875");
        request.setRoutePath("/admin/home");
        request.setPageType("business_block");
        request.setTableName("");
        request.setLayoutJson(layoutJson);
        request.setActionJson("{\"toolbarActions\":[],\"rowActions\":[]}");
        return request;
    }

    private String validBusinessBlockLayout()
    {
        return "{\"canvas\":\"business-blocks-v1\",\"blocks\":[{\"id\":\"cart_001\",\"blockCode\":\"cart\",\"config\":{"
                + "\"cartTable\":\"shop_cart\","
                + "\"productTable\":\"shop_product\","
                + "\"cartProductId\":\"product_id\","
                + "\"productId\":\"id\","
                + "\"productName\":\"name\","
                + "\"price\":\"price\","
                + "\"quantity\":\"quantity\","
                + "\"userId\":\"user_id\","
                + "\"stock\":\"stock\""
                + "}}]}";
    }

    private String validAdminChartLayout()
    {
        return "{\"canvas\":\"business-blocks-v1\",\"blocks\":[{"
                + "\"id\":\"admin_line_chart_001\","
                + "\"blockCode\":\"admin_line_chart\","
                + "\"name\":\"近 30 天销售趋势\","
                + "\"span\":12,"
                + "\"dataset\":{"
                + "\"schema\":\"single-table-aggregate-v1\","
                + "\"table\":\"shop_order\","
                + "\"dimension\":{\"field\":\"create_time\",\"timeUnit\":\"day\"},"
                + "\"metrics\":[{\"field\":\"amount\",\"aggregate\":\"sum\",\"alias\":\"salesAmount\",\"label\":\"销售额\"}],"
                + "\"filters\":[{\"field\":\"status\",\"operator\":\"eq\",\"value\":\"PAID\"}],"
                + "\"sort\":{\"by\":\"dimension\",\"order\":\"asc\"},"
                + "\"limit\":100},"
                + "\"display\":{\"showLegend\":true}}]}";
    }

    private ServiceException expectBusinessBlockUpdateFailure(String layoutJson)
    {
        FrontProjectPageDesign current = currentBusinessBlockDesign();
        when(frontProjectService.getProject(7L, 10L)).thenReturn(project());
        when(pageDesignMapper.selectFrontProjectPageDesignById(88L)).thenReturn(current);
        when(frontProjectTableMapper.selectTablesByProjectId(10L)).thenReturn(Arrays.asList(cartTable(), productTable()));
        when(frontProjectColumnMapper.selectColumnsByTableId(201L)).thenReturn(Arrays.asList(
                column("id", "id", "0", "1", "1"),
                column("product_id", "productId", "0", "1", "1"),
                column("quantity", "quantity", "0", "1", "1"),
                column("user_id", "userId", "0", "1", "1")));
        when(frontProjectColumnMapper.selectColumnsByTableId(202L)).thenReturn(Arrays.asList(
                column("id", "id", "0", "1", "1"),
                column("name", "name", "0", "1", "1"),
                column("price", "price", "0", "1", "1"),
                column("stock", "stock", "0", "1", "1")));
        final FrontendPageDesignRequest request = businessBlockRequest(layoutJson);
        return expectServiceException(new ThrowingRunnable()
        {
            @Override
            public void run()
            {
                service.updatePageDesign(7L, 10L, 88L, request);
            }
        });
    }

    private ServiceException expectServiceException(ThrowingRunnable runnable)
    {
        try
        {
            runnable.run();
        }
        catch (ServiceException e)
        {
            return e;
        }
        throw new AssertionError("Expected ServiceException");
    }

    private interface ThrowingRunnable
    {
        void run();
    }
}
