package com.yaoai.pipeline.service.impl;

import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.ai.core.model.ChatRequest;
import com.yaoai.ai.core.service.LlmService;
import com.yaoai.billing.service.BillingService;
import com.yaoai.common.context.UserContext;
import com.yaoai.common.exception.BizException;
import com.yaoai.domain.entity.Episode;
import com.yaoai.domain.entity.Scene;
import com.yaoai.domain.entity.Storyboard;
import com.yaoai.domain.mapper.CharacterMapper;
import com.yaoai.domain.mapper.EpisodeMapper;
import com.yaoai.domain.mapper.SceneMapper;
import com.yaoai.domain.mapper.StoryboardMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.InOrder;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class StoryboardPipelineServiceImplTest {

    @Mock
    private LlmService llmService;
    @Mock
    private StoryboardMapper storyboardMapper;
    @Mock
    private EpisodeMapper episodeMapper;
    @Mock
    private CharacterMapper characterMapper;
    @Mock
    private SceneMapper sceneMapper;
    @Mock
    private BillingService billingService;

    private StoryboardPipelineServiceImpl service;

    @BeforeEach
    void setUp() {
        service = new StoryboardPipelineServiceImpl(
                llmService,
                storyboardMapper,
                episodeMapper,
                characterMapper,
                sceneMapper,
                new ObjectMapper(),
                billingService
        );
        UserContext.set(1001L);
    }

    @AfterEach
    void tearDown() {
        UserContext.clear();
    }

    @Test
    void generateStoryboards_shouldSplitSceneRefFromCharacters() {
        Episode episode = new Episode();
        episode.setId(11L);
        episode.setTitle("ep-1");
        episode.setSummary("summary");
        when(episodeMapper.selectById(11L)).thenReturn(episode);
        when(characterMapper.findByProject(22L, 33L)).thenReturn(List.of());
        when(sceneMapper.findByProject(22L, 33L)).thenReturn(List.of(scene("cafe", null), scene("street", "outdoor")));
        when(storyboardMapper.insert(any(Storyboard.class))).thenAnswer(invocation -> 1);
        when(llmService.chat(any(ChatRequest.class))).thenReturn("""
                [{
                  "sequence_num": 1,
                  "scene_number": "001",
                  "short_description": "shot",
                  "detailed_description": "detail",
                  "characters": "@hero, @street, @cafe, @friend",
                  "dialogues": "",
                  "camera_direction": "push",
                  "composition_guide": "medium",
                  "duration_seconds": 5,
                  "start_frame_prompt": "prompt",
                  "motion_script": "move"
                }]
                """);

        List<Storyboard> storyboards = service.generateStoryboards(11L, 22L, 33L);

        assertEquals(1, storyboards.size());
        assertEquals("@street", storyboards.get(0).getSceneRef());
        assertEquals("@hero,@friend", storyboards.get(0).getCharacters());

        ArgumentCaptor<ChatRequest> requestCaptor = ArgumentCaptor.forClass(ChatRequest.class);
        verify(llmService).chat(requestCaptor.capture());
        String systemMessage = requestCaptor.getValue().getMessages().get(0).getContent();
        assertTrue(systemMessage.contains("- @cafe ():"));
        assertTrue(!systemMessage.contains("- @cafe (outdoor):"));
    }

    @Test
    void generatePrompt_shouldPersistVideoPrompt() {
        Storyboard storyboard = new Storyboard();
        storyboard.setId(7L);
        storyboard.setTenantId(33L);
        storyboard.setProjectId(22L);
        storyboard.setEpisodeId(11L);
        storyboard.setSequenceNum(1);
        storyboard.setShortDescription("short");
        storyboard.setDetailedDescription("detail");
        storyboard.setCharacters("@hero");
        storyboard.setSceneRef("@cafe");
        storyboard.setDialogues("hero: hello");
        storyboard.setCameraDirection("push");
        storyboard.setCompositionGuide("medium");
        storyboard.setDurationSeconds(5);
        when(storyboardMapper.selectById(7L)).thenReturn(storyboard);
        when(storyboardMapper.updateById(any(Storyboard.class))).thenReturn(1);
        when(episodeMapper.selectById(11L)).thenReturn(new Episode());
        when(characterMapper.findByProject(22L, 33L)).thenReturn(List.of());
        when(sceneMapper.findByProject(22L, 33L)).thenReturn(List.of());
        when(llmService.chat(any(ChatRequest.class))).thenReturn("persisted prompt");

        String prompt = service.generatePrompt(7L, 33L);

        assertEquals("persisted prompt", prompt);

        ArgumentCaptor<Storyboard> updateCaptor = ArgumentCaptor.forClass(Storyboard.class);
        verify(storyboardMapper).updateById(updateCaptor.capture());
        Storyboard persisted = updateCaptor.getValue();
        assertEquals(7L, persisted.getId());
        assertEquals("persisted prompt", persisted.getVideoPrompt());

        InOrder inOrder = inOrder(billingService, storyboardMapper);
        inOrder.verify(billingService).charge(any());
        inOrder.verify(storyboardMapper).updateById(any(Storyboard.class));

        ArgumentCaptor<ChatRequest> requestCaptor = ArgumentCaptor.forClass(ChatRequest.class);
        verify(llmService).chat(requestCaptor.capture());
        String userMessage = requestCaptor.getValue().getMessages().get(1).getContent();
        assertTrue(userMessage.contains("@cafe"));
    }

    @Test
    void generatePrompt_shouldFallbackToLegacySceneRefsStoredInCharacters() {
        Storyboard storyboard = new Storyboard();
        storyboard.setId(8L);
        storyboard.setTenantId(33L);
        storyboard.setProjectId(22L);
        storyboard.setEpisodeId(11L);
        storyboard.setSequenceNum(2);
        storyboard.setShortDescription("legacy");
        storyboard.setDetailedDescription("legacy detail");
        storyboard.setCharacters("@hero,@cafe");
        storyboard.setDialogues("");
        storyboard.setCameraDirection("static");
        storyboard.setCompositionGuide("wide");
        storyboard.setDurationSeconds(6);
        when(storyboardMapper.selectById(8L)).thenReturn(storyboard);
        when(storyboardMapper.updateById(any(Storyboard.class))).thenReturn(1);
        when(episodeMapper.selectById(11L)).thenReturn(new Episode());
        when(characterMapper.findByProject(22L, 33L)).thenReturn(List.of());
        when(sceneMapper.findByProject(22L, 33L)).thenReturn(List.of(scene("cafe", null)));
        when(llmService.chat(any(ChatRequest.class))).thenReturn("legacy prompt");

        service.generatePrompt(8L, 33L);

        ArgumentCaptor<ChatRequest> requestCaptor = ArgumentCaptor.forClass(ChatRequest.class);
        verify(llmService).chat(requestCaptor.capture());
        String userMessage = requestCaptor.getValue().getMessages().get(1).getContent();
        assertTrue(userMessage.contains("Character refs: @hero"));
        assertTrue(userMessage.contains("Scene refs: @cafe"));
        assertTrue(userMessage.contains("- @cafe ():"));
        assertTrue(!userMessage.contains("- @cafe (outdoor):"));
    }

    @Test
    void update_shouldClearVideoPromptWhenStoryboardBodyChanges() {
        Storyboard existing = new Storyboard();
        existing.setId(9L);
        existing.setTenantId(33L);
        existing.setShortDescription("old");
        existing.setVideoPrompt("stale prompt");
        Storyboard refreshed = new Storyboard();
        refreshed.setId(9L);
        refreshed.setTenantId(33L);
        when(storyboardMapper.updateById(any(Storyboard.class))).thenReturn(1);
        when(storyboardMapper.update(org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
        when(storyboardMapper.selectById(9L)).thenReturn(existing, refreshed);

        Storyboard patch = new Storyboard();
        patch.setShortDescription("new");

        Storyboard result = service.update(9L, 33L, patch);

        assertSame(refreshed, result);

        ArgumentCaptor<Storyboard> updateCaptor = ArgumentCaptor.forClass(Storyboard.class);
        verify(storyboardMapper).updateById(updateCaptor.capture());
        Storyboard updated = updateCaptor.getValue();
        assertEquals(9L, updated.getId());
        assertEquals(33L, updated.getTenantId());

        ArgumentCaptor<UpdateWrapper<Storyboard>> wrapperCaptor = ArgumentCaptor.forClass(UpdateWrapper.class);
        verify(storyboardMapper).update(org.mockito.ArgumentMatchers.isNull(), wrapperCaptor.capture());
        UpdateWrapper<Storyboard> wrapper = wrapperCaptor.getValue();
        assertTrue(wrapper.getSqlSet().contains("video_prompt"));
        assertTrue(wrapper.getSqlSegment().contains("id"));
        assertTrue(wrapper.getSqlSegment().contains("tenant_id"));

        Map<String, Object> paramPairs = ((AbstractWrapper<Storyboard, ?, ?>) wrapper).getParamNameValuePairs();
        assertTrue(paramPairs.containsValue(9L));
        assertTrue(paramPairs.containsValue(33L));
    }

    @Test
    void update_shouldKeepVideoPromptWhenStoryboardBodyDoesNotChange() {
        Storyboard existing = new Storyboard();
        existing.setId(9L);
        existing.setTenantId(33L);
        existing.setShortDescription("same");
        when(storyboardMapper.updateById(any(Storyboard.class))).thenReturn(1);
        when(storyboardMapper.selectById(9L)).thenReturn(existing, existing);

        Storyboard patch = new Storyboard();
        patch.setShortDescription("same");

        Storyboard result = service.update(9L, 33L, patch);

        assertSame(existing, result);
        verify(storyboardMapper).updateById(any(Storyboard.class));
        verify(storyboardMapper, never()).update(org.mockito.ArgumentMatchers.isNull(), any());
    }

    @Test
    void update_shouldBeTransactionalAndFailWhenPromptInvalidationFails() throws Exception {
        Storyboard existing = new Storyboard();
        existing.setId(9L);
        existing.setTenantId(33L);
        existing.setShortDescription("old");
        when(storyboardMapper.selectById(9L)).thenReturn(existing);
        when(storyboardMapper.updateById(any(Storyboard.class))).thenReturn(1);
        when(storyboardMapper.update(org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(0);

        Storyboard patch = new Storyboard();
        patch.setShortDescription("new");

        assertThrows(BizException.class, () -> service.update(9L, 33L, patch));
        assertTrue(StoryboardPipelineServiceImpl.class
                .getMethod("update", Long.class, Long.class, Storyboard.class)
                .isAnnotationPresent(Transactional.class));
    }

    @Test
    void generatePrompt_shouldNotPersistVideoPromptWhenChargeFails() throws Exception {
        Storyboard storyboard = promptStoryboard(7L, 11L, 22L, 33L);
        storyboard.setSceneRef("@cafe");
        when(storyboardMapper.selectById(7L)).thenReturn(storyboard);
        when(episodeMapper.selectById(11L)).thenReturn(new Episode());
        when(characterMapper.findByProject(22L, 33L)).thenReturn(List.of());
        when(sceneMapper.findByProject(22L, 33L)).thenReturn(List.of());
        when(llmService.chat(any(ChatRequest.class))).thenReturn("persisted prompt");
        doThrow(new BizException("TEST", "charge failed")).when(billingService).charge(any());

        assertThrows(BizException.class, () -> service.generatePrompt(7L, 33L));
        verify(storyboardMapper, never()).updateById(org.mockito.ArgumentMatchers.<Storyboard>argThat(storyboardArg ->
                "persisted prompt".equals(storyboardArg.getVideoPrompt())
        ));
        assertTrue(StoryboardPipelineServiceImpl.class
                .getMethod("generatePrompt", Long.class, Long.class)
                .isAnnotationPresent(Transactional.class));
    }

    @Test
    void populateMissingVideoPromptsByEpisode_shouldFillOnlyMissingRows() {
        Storyboard missingA = new Storyboard();
        missingA.setId(1L);
        Storyboard missingB = new Storyboard();
        missingB.setId(2L);
        Storyboard full = new Storyboard();
        full.setId(3L);
        when(storyboardMapper.updateById(any(Storyboard.class))).thenReturn(1);
        when(storyboardMapper.findMissingVideoPromptByEpisode(44L, 33L)).thenReturn(List.of(missingA, missingB));
        when(storyboardMapper.findByEpisode(44L, 33L)).thenReturn(List.of(missingA, missingB, full));
        when(storyboardMapper.selectById(1L)).thenReturn(promptStoryboard(1L, 44L, 22L, 33L));
        when(storyboardMapper.selectById(2L)).thenReturn(promptStoryboard(2L, 44L, 22L, 33L));
        when(episodeMapper.selectById(44L)).thenReturn(new Episode());
        when(characterMapper.findByProject(anyLong(), anyLong())).thenReturn(List.of());
        when(sceneMapper.findByProject(anyLong(), anyLong())).thenReturn(List.of());
        when(llmService.chat(any(ChatRequest.class))).thenReturn("prompt-1", "prompt-2");

        List<Storyboard> result = service.populateMissingVideoPromptsByEpisode(44L, 33L);

        assertEquals(3, result.size());
        verify(storyboardMapper, never()).selectById(3L);
        ArgumentCaptor<Storyboard> updateCaptor = ArgumentCaptor.forClass(Storyboard.class);
        verify(storyboardMapper, times(2)).updateById(updateCaptor.capture());
        assertTrue(updateCaptor.getAllValues().stream().map(Storyboard::getId).toList().containsAll(List.of(1L, 2L)));
    }

    private static Scene scene(String name, String sceneType) {
        Scene scene = new Scene();
        scene.setName(name);
        scene.setSceneType(sceneType);
        return scene;
    }

    private static Storyboard promptStoryboard(Long id, Long episodeId, Long projectId, Long tenantId) {
        Storyboard storyboard = new Storyboard();
        storyboard.setId(id);
        storyboard.setEpisodeId(episodeId);
        storyboard.setProjectId(projectId);
        storyboard.setTenantId(tenantId);
        storyboard.setSequenceNum(id.intValue());
        storyboard.setShortDescription("short-" + id);
        storyboard.setDetailedDescription("detail-" + id);
        storyboard.setCharacters("@hero");
        storyboard.setDurationSeconds(5);
        storyboard.setDialogues("");
        storyboard.setCameraDirection("push");
        storyboard.setCompositionGuide("medium");
        return storyboard;
    }
}
