package com.agent.management.engine;

import com.agent.management.engine.PlannerService.PlannerDecision;
import com.agent.management.service.AiModelService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;

import java.util.HashMap;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
 * {@link PlannerService} 单元测试。
 *
 * <p>覆盖两类核心场景：</p>
 * <ul>
 *   <li>{@link PlannerService#fallbackToNextEdge}：纯静态兜底逻辑（4 个分支）</li>
 *   <li>{@link PlannerService#decideNext}：LLM 调用与 JSON 解析（含重试与失败）</li>
 * </ul>
 *
 * <p>设计要点：</p>
 * <ul>
 *   <li>Spring AI 的 fluent chain（prompt/user/call/content）用 4 个 mock 串起来，
 *       与 {@code StructuredOutputFromTextTest} 同款套路</li>
 *   <li>测试不依赖 Spring 容器，可直接 new PlannerService()</li>
 * </ul>
 */
class PlannerServiceTest {

    private PlannerService plannerService;
    private AiModelService aiModelService;
    private ChatClient.Builder fallbackBuilder;
    private ChatClient client;
    private ChatClient.ChatClientRequestSpec spec;
    private ChatClient.CallResponseSpec callResponse;

    @BeforeEach
    void setUp() {
        plannerService = new PlannerService();
        aiModelService = mock(AiModelService.class);
        fallbackBuilder = mock(ChatClient.Builder.class);
        client = mock(ChatClient.class);
        spec = mock(ChatClient.ChatClientRequestSpec.class);
        callResponse = mock(ChatClient.CallResponseSpec.class);

        // 串起 fluent chain：client.prompt().user(any).call().content()
        when(aiModelService.getChatClientOrFallback(any())).thenReturn(client);
        when(client.prompt()).thenReturn(spec);
        when(spec.user(anyString())).thenReturn(spec);
        when(spec.call()).thenReturn(callResponse);
    }

    // ============================ fallbackToNextEdge ============================

    /**
     * 辅助：构造简单 DAG JSON。
     */
    private static String graph(String nodesDecl, String edgesDecl) {
        return "{\"nodes\":[" + nodesDecl + "],\"edges\":[" + edgesDecl + "]}";
    }

    @Test
    void fallback_walksFirstOutgoingEdge_whenExists() {
        // 场景：A→B→C，A 失败，应走 A 的第一条出边到 B
        String g = graph(
                "{\"id\":\"A\",\"type\":\"llm\",\"data\":{\"label\":\"A\"}},"
                + "{\"id\":\"B\",\"type\":\"llm\",\"data\":{\"label\":\"B\"}},"
                + "{\"id\":\"C\",\"type\":\"output\",\"data\":{\"label\":\"C\"}}",
                "{\"source\":\"A\",\"target\":\"B\"},"
                + "{\"source\":\"B\",\"target\":\"C\"}"
        );
        DagResolver.ResolvedDag dag = DagResolver.resolve(g);

        PlannerDecision d = PlannerService.fallbackToNextEdge(dag, "A");

        assertThat(d.getNextNodeId()).isEqualTo("B");
        assertThat(d.getReason()).contains("画布参考路径");
    }

    @Test
    void fallback_targetsOutputNode_whenNoOutgoingEdgeButOutputExists() {
        // 场景：孤立节点 A，画布上有 output 节点 O，A 无出边时应直接跳到 O
        String g = graph(
                "{\"id\":\"A\",\"type\":\"llm\",\"data\":{\"label\":\"A\"}},"
                + "{\"id\":\"O\",\"type\":\"output\",\"data\":{\"label\":\"O\"}}",
                ""
        );
        DagResolver.ResolvedDag dag = DagResolver.resolve(g);

        PlannerDecision d = PlannerService.fallbackToNextEdge(dag, "A");

        assertThat(d.getNextNodeId()).isEqualTo("O");
        assertThat(d.getReason()).contains("output");
    }

    @Test
    void fallback_targetsLastNodeInLevels_whenNoOutputNode() {
        // 场景：A→B（两个都是 llm，无 output 节点），A 无 B 之外的出边时，
        // 应回退到 levels 最后一个非空层的最后一个节点（即 B）
        String g = graph(
                "{\"id\":\"A\",\"type\":\"llm\",\"data\":{\"label\":\"A\"}},"
                + "{\"id\":\"B\",\"type\":\"llm\",\"data\":{\"label\":\"B\"}}",
                "{\"source\":\"A\",\"target\":\"B\"}"
        );
        DagResolver.ResolvedDag dag = DagResolver.resolve(g);

        // 直接测试无出边的情形：从 B 节点 fallback
        PlannerDecision d = PlannerService.fallbackToNextEdge(dag, "B");

        // B 无出边、无 output 节点 → 回到 levels 最后一个节点（B 自身）
        assertThat(d.getNextNodeId()).isEqualTo("B");
        assertThat(d.getReason()).contains("收尾");
    }

    @Test
    void fallback_throws_whenCanvasEmpty() {
        // 极端场景：画布完全无节点
        String g = graph("", "");
        DagResolver.ResolvedDag dag = DagResolver.resolve(g);

        assertThatThrownBy(() -> PlannerService.fallbackToNextEdge(dag, "A"))
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("无任何节点");
    }

    // ============================ decideNext ============================

    @Test
    void decideNext_parsesPlainJsonResponse() {
        // 场景：LLM 返回标准 JSON，应解析出 next + reason
        DagResolver.ResolvedDag dag = DagResolver.resolve(graph(
                "{\"id\":\"A\",\"type\":\"llm\",\"data\":{\"label\":\"A\"}},"
                + "{\"id\":\"B\",\"type\":\"output\",\"data\":{\"label\":\"B\"}}",
                "{\"source\":\"A\",\"target\":\"B\"}"
        ));
        when(callResponse.content()).thenReturn("{\"next\":\"B\",\"reason\":\"任务完成\"}");

        PlannerDecision d = plannerService.decideNext(
                dag, "A", new HashMap<>(), new HashMap<>(),
                aiModelService, fallbackBuilder, false, null, null);

        assertThat(d.getNextNodeId()).isEqualTo("B");
        assertThat(d.getReason()).isEqualTo("任务完成");
        // 应只调用 1 次（首次成功）
        verify(callResponse, times(1)).content();
    }

    @Test
    void decideNext_parsesJsonWrappedInCodeFence() {
        // 场景：LLM 用 ```json ... ``` 包裹，仍应正常解析
        DagResolver.ResolvedDag dag = DagResolver.resolve(graph(
                "{\"id\":\"A\",\"type\":\"llm\",\"data\":{\"label\":\"A\"}},"
                + "{\"id\":\"B\",\"type\":\"output\",\"data\":{\"label\":\"B\"}}",
                "{\"source\":\"A\",\"target\":\"B\"}"
        ));
        when(callResponse.content()).thenReturn(
                "好的，我的决策是：\n```json\n{\"next\":\"B\",\"reason\":\"done\"}\n```\n");

        PlannerDecision d = plannerService.decideNext(
                dag, "A", new HashMap<>(), new HashMap<>(),
                aiModelService, fallbackBuilder, false, null, null);

        assertThat(d.getNextNodeId()).isEqualTo("B");
        assertThat(d.getReason()).isEqualTo("done");
    }

    @Test
    void decideNext_retriesOnceWhenFirstResponseInvalid() {
        // 场景：首次返回空 next（解析失败），第二次返回正常 JSON
        DagResolver.ResolvedDag dag = DagResolver.resolve(graph(
                "{\"id\":\"A\",\"type\":\"llm\",\"data\":{\"label\":\"A\"}},"
                + "{\"id\":\"B\",\"type\":\"output\",\"data\":{\"label\":\"B\"}}",
                "{\"source\":\"A\",\"target\":\"B\"}"
        ));
        when(callResponse.content())
                .thenReturn("{\"next\":\"\",\"reason\":\"不知道\"}")  // 首次：next 为空，解析为 null
                .thenReturn("{\"next\":\"B\",\"reason\":\"再次尝试\"}"); // 第二次成功

        PlannerDecision d = plannerService.decideNext(
                dag, "A", new HashMap<>(), new HashMap<>(),
                aiModelService, fallbackBuilder, false, null, null);

        assertThat(d.getNextNodeId()).isEqualTo("B");
        // 应调用 2 次 content()
        verify(callResponse, times(2)).content();
    }

    @Test
    void decideNext_throwsWhenAllAttemptsFail() {
        // 场景：两次都返回空 next，应抛 IllegalStateException
        DagResolver.ResolvedDag dag = DagResolver.resolve(graph(
                "{\"id\":\"A\",\"type\":\"llm\",\"data\":{\"label\":\"A\"}},"
                + "{\"id\":\"B\",\"type\":\"output\",\"data\":{\"label\":\"B\"}}",
                "{\"source\":\"A\",\"target\":\"B\"}"
        ));
        when(callResponse.content()).thenReturn("{\"next\":\"\",\"reason\":\"\"}");

        assertThatThrownBy(() -> plannerService.decideNext(
                dag, "A", new HashMap<>(), new HashMap<>(),
                aiModelService, fallbackBuilder, false, null, null))
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("Planner 决策失败");
        // 应重试，共调用 2 次
        verify(callResponse, times(2)).content();
    }

    @Test
    void decideNext_defaultsReasonWhenMissing() {
        // 场景：LLM 只返回 next，无 reason 字段，应填默认值
        DagResolver.ResolvedDag dag = DagResolver.resolve(graph(
                "{\"id\":\"A\",\"type\":\"llm\",\"data\":{\"label\":\"A\"}},"
                + "{\"id\":\"B\",\"type\":\"output\",\"data\":{\"label\":\"B\"}}",
                "{\"source\":\"A\",\"target\":\"B\"}"
        ));
        when(callResponse.content()).thenReturn("{\"next\":\"B\"}");

        PlannerDecision d = plannerService.decideNext(
                dag, "A", new HashMap<>(), new HashMap<>(),
                aiModelService, fallbackBuilder, false, null, null);

        assertThat(d.getNextNodeId()).isEqualTo("B");
        assertThat(d.getReason()).isEqualTo("（未提供）");
    }

    @Test
    void decideNext_includesVisitCountInPrompt() {
        // 场景：节点已访问 3 次，prompt 中应包含"已访问 3 次"提示。
        // 通过捕获 prompt 验证 PlannerService 向 LLM 传递了正确上下文。
        DagResolver.ResolvedDag dag = DagResolver.resolve(graph(
                "{\"id\":\"A\",\"type\":\"llm\",\"data\":{\"label\":\"A\"}},"
                + "{\"id\":\"B\",\"type\":\"output\",\"data\":{\"label\":\"B\"}}",
                "{\"source\":\"A\",\"target\":\"B\"}"
        ));
        when(callResponse.content()).thenReturn("{\"next\":\"B\",\"reason\":\"ok\"}");

        Map<String, Integer> visitCounter = new HashMap<>();
        visitCounter.put("A", 3);
        plannerService.decideNext(dag, "A", new HashMap<>(), visitCounter,
                aiModelService, fallbackBuilder, false, null, null);

        // 验证 prompt 中含"已访问 3 次"字样
        org.mockito.ArgumentCaptor<String> promptCaptor =
                org.mockito.ArgumentCaptor.forClass(String.class);
        verify(spec).user(promptCaptor.capture());
        assertThat(promptCaptor.getValue()).contains("已访问 3 次");
    }

    @Test
    void decideNext_includesFailureContextInPrompt_whenLastFailed() {
        // 场景：上一次节点重试用尽后失败，prompt 应带上失败原因，提示 Planner 规避
        DagResolver.ResolvedDag dag = DagResolver.resolve(graph(
                "{\"id\":\"A\",\"type\":\"llm\",\"data\":{\"label\":\"A\"}},"
                + "{\"id\":\"B\",\"type\":\"output\",\"data\":{\"label\":\"B\"}},"
                + "{\"id\":\"C\",\"type\":\"llm\",\"data\":{\"label\":\"C\"}}",
                "{\"source\":\"A\",\"target\":\"B\"},{\"source\":\"A\",\"target\":\"C\"}"
        ));
        when(callResponse.content()).thenReturn("{\"next\":\"C\",\"reason\":\"绕过失败\"}");

        plannerService.decideNext(dag, "A", new HashMap<>(), new HashMap<>(),
                aiModelService, fallbackBuilder, true, "LLM 调用超时", null);

        org.mockito.ArgumentCaptor<String> promptCaptor =
                org.mockito.ArgumentCaptor.forClass(String.class);
        verify(spec).user(promptCaptor.capture());
        assertThat(promptCaptor.getValue()).contains("LLM 调用超时");
        assertThat(promptCaptor.getValue()).contains("已重试用尽");
    }

    @Test
    void decideNext_includesLastNodeOutputInPrompt() {
        // 场景：上一节点 output 含"用户有新的需求：补充 XXX"，prompt 应直接展示该文本
        // （Planner 据此判断需要重访当前节点，而非走向 output）
        DagResolver.ResolvedDag dag = DagResolver.resolve(graph(
                "{\"id\":\"A\",\"type\":\"llm\",\"data\":{\"label\":\"A\"}},"
                + "{\"id\":\"B\",\"type\":\"output\",\"data\":{\"label\":\"B\"}}",
                "{\"source\":\"A\",\"target\":\"B\"}"
        ));
        when(callResponse.content()).thenReturn("{\"next\":\"A\",\"reason\":\"处理新需求\"}");

        Map<String, Object> lastOutput = new HashMap<>();
        Map<String, Object> data = new HashMap<>();
        data.put("result", "用户有新的需求：补充 YYY 项内容");
        lastOutput.put("status", 200);
        lastOutput.put("data", data);

        plannerService.decideNext(dag, "A", new HashMap<>(), new HashMap<>(),
                aiModelService, fallbackBuilder, false, null, lastOutput);

        org.mockito.ArgumentCaptor<String> promptCaptor =
                org.mockito.ArgumentCaptor.forClass(String.class);
        verify(spec).user(promptCaptor.capture());
        String prompt = promptCaptor.getValue();
        assertThat(prompt).contains("用户有新的需求：补充 YYY 项内容");
        assertThat(prompt).contains("上一节点（A）的输出");
        // 规则 3 新版本应包含"重访已执行过的节点是允许的"
        assertThat(prompt).contains("重访已执行过的节点是允许的");
    }

    @Test
    void decideNext_usesPlaceholderWhenLastNodeOutputNull() {
        // 场景：lastNodeOutput 为 null（节点失败无输出时），prompt 段落应降级为"(节点无输出)"
        DagResolver.ResolvedDag dag = DagResolver.resolve(graph(
                "{\"id\":\"A\",\"type\":\"llm\",\"data\":{\"label\":\"A\"}},"
                + "{\"id\":\"B\",\"type\":\"output\",\"data\":{\"label\":\"B\"}}",
                "{\"source\":\"A\",\"target\":\"B\"}"
        ));
        when(callResponse.content()).thenReturn("{\"next\":\"B\",\"reason\":\"done\"}");

        plannerService.decideNext(dag, "A", new HashMap<>(), new HashMap<>(),
                aiModelService, fallbackBuilder, false, null, null);

        org.mockito.ArgumentCaptor<String> promptCaptor =
                org.mockito.ArgumentCaptor.forClass(String.class);
        verify(spec).user(promptCaptor.capture());
        assertThat(promptCaptor.getValue()).contains("(节点无输出)");
    }

    /**
     * 场景：LLM 在 reason 中嵌入了未转义的半角双引号（典型错误），
     * Jackson 严格解析失败，应降级用正则提取 next，避免误判为决策失败而走向画布兜底。
     *
     * <p>对应实际日志（runId=aea1b348, 2026-08-09 22:41:05）：
     * LLM 返回 {@code {"next":"node-1-1786166315547","reason":"输出含"用户新需求"..."}}，
     * 旧逻辑直接抛 IllegalStateException 触发兜底走 output，错失重访机会。</p>
     */
    @Test
    void decideNext_recoversNextViaRegex_whenReasonBreaksJson() {
        DagResolver.ResolvedDag dag = DagResolver.resolve(graph(
                "{\"id\":\"A\",\"type\":\"llm\",\"data\":{\"label\":\"A\"}},"
                + "{\"id\":\"B\",\"type\":\"output\",\"data\":{\"label\":\"B\"}}",
                "{\"source\":\"A\",\"target\":\"B\"}"
        ));
        // LLM 返回的 reason 内含半角双引号（破坏 JSON 结构）
        when(callResponse.content()).thenReturn(
                "{\"next\":\"B\",\"reason\":\"输出含\"用户新需求\"信号，需重访\"}");

        PlannerDecision d = plannerService.decideNext(
                dag, "A", new HashMap<>(), new HashMap<>(),
                aiModelService, fallbackBuilder, false, null, null);

        // 验证：next 通过正则降级提取成功
        assertThat(d.getNextNodeId()).isEqualTo("B");
        // reason 因解析失败被替换为占位符
        assertThat(d.getReason()).contains("解析失败");
        // 应只调用 1 次 content()（首次降级提取成功，无需重试）
        verify(callResponse, times(1)).content();
    }

    @Test
    void plannerDecision_toMapProducesImmutableMap() {
        // 验证 PlannerDecision.toMap() 返回不可变 Map（前端 SSE 序列化用）
        PlannerDecision d = new PlannerDecision("B", "理由");
        Map<String, Object> m = d.toMap();

        assertThat(m).containsEntry("next", "B").containsEntry("reason", "理由");
        assertThatThrownBy(() -> m.put("next", "C"))
                .isInstanceOf(UnsupportedOperationException.class);
    }
}
