001/*
002 * Copyright 2025 devteam@scivicslab.com
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing,
011 * software distributed under the License is distributed on an
012 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
013 * either express or implied.  See the License for the
014 * specific language governing permissions and limitations
015 * under the License.
016 */
017
018package com.scivicslab.actoriac.report.sections.basic;
019
020import com.scivicslab.actoriac.report.SectionBuilder;
021
022import java.sql.Connection;
023import java.sql.PreparedStatement;
024import java.sql.ResultSet;
025import java.sql.Timestamp;
026import java.time.format.DateTimeFormatter;
027import java.util.ArrayList;
028import java.util.List;
029import java.util.logging.Logger;
030
031/**
032 * POJO section builder that outputs workflow transition history.
033 *
034 * <p>Pure business logic - no {@code CallableByActionName}.
035 * Use {@link TransitionHistorySectionIIAR} to expose as an actor.</p>
036 *
037 * <p>Retrieves transition logs from the database and displays them
038 * in a human-readable format with success/failure status.</p>
039 *
040 * <h2>Output example:</h2>
041 * <pre>
042 * [Transition History: nodeGroup]
043 * o [2026-01-30 10:15:23] 0 -> 1 [Initialize]
044 * o [2026-01-30 10:15:24] 1 -> 2 [Collect data]
045 * x [2026-01-30 10:15:25] 2 -> 3 [Process] Connection refused
046 *
047 * Summary: 3 transitions, 2 succeeded, 1 failed
048 * </pre>
049 *
050 * @author devteam@scivicslab.com
051 * @since 2.16.0
052 */
053public class TransitionHistorySection implements SectionBuilder {
054
055    private static final Logger logger = Logger.getLogger(TransitionHistorySection.class.getName());
056    private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
057
058    private Connection connection;
059    private long sessionId = -1;
060    private String targetActorName;
061    private boolean includeChildren = false;
062
063    /**
064     * Sets the database connection for log queries.
065     *
066     * @param connection the JDBC connection to the H2 log database
067     */
068    public void setConnection(Connection connection) {
069        this.connection = connection;
070    }
071
072    /**
073     * Sets the session ID to query logs from.
074     *
075     * @param sessionId the session ID
076     */
077    public void setSessionId(long sessionId) {
078        this.sessionId = sessionId;
079    }
080
081    /**
082     * Sets the target actor name to display transitions for.
083     *
084     * @param targetActorName the target actor name (e.g., "nodeGroup", "node-server1")
085     */
086    public void setTargetActorName(String targetActorName) {
087        this.targetActorName = targetActorName;
088    }
089
090    /**
091     * Sets whether to include child nodes in the output.
092     *
093     * <p>When true and target is "nodeGroup", transitions from all
094     * child nodes are also included in the output.</p>
095     *
096     * @param includeChildren true to include children
097     */
098    public void setIncludeChildren(boolean includeChildren) {
099        this.includeChildren = includeChildren;
100    }
101
102    @Override
103    public String generate() {
104        if (connection == null || sessionId < 0) {
105            logger.warning("TransitionHistorySection: connection or sessionId not set");
106            return "";
107        }
108
109        if (targetActorName == null || targetActorName.isEmpty()) {
110            targetActorName = "nodeGroup";  // Default target
111        }
112
113        try {
114            if (includeChildren && "nodeGroup".equals(targetActorName)) {
115                return buildAggregatedOutput();
116            } else {
117                return buildSingleActorOutput();
118            }
119        } catch (Exception e) {
120            logger.warning("TransitionHistorySection: error: " + e.getMessage());
121            return "";
122        }
123    }
124
125    /**
126     * Builds output for a single actor.
127     */
128    private String buildSingleActorOutput() throws Exception {
129        List<TransitionEntry> entries = queryTransitions(targetActorName);
130        if (entries.isEmpty()) {
131            return "";  // No transitions, skip this section
132        }
133
134        StringBuilder sb = new StringBuilder();
135        sb.append("[Transition History: ").append(targetActorName).append("]\n");
136
137        int succeeded = 0;
138        int failed = 0;
139
140        for (TransitionEntry entry : entries) {
141            sb.append(entry.success ? "o " : "x ");
142            sb.append("[").append(entry.timestamp).append("] ");
143            sb.append(entry.label);
144            if (entry.note != null && !entry.note.isEmpty()) {
145                sb.append(" [").append(entry.note).append("]");
146            }
147            if (!entry.success && entry.errorMessage != null && !entry.errorMessage.isEmpty()) {
148                sb.append(" ").append(entry.errorMessage);
149            }
150            sb.append("\n");
151
152            if (entry.success) {
153                succeeded++;
154            } else {
155                failed++;
156            }
157        }
158
159        sb.append("\nSummary: ").append(entries.size()).append(" transitions, ");
160        sb.append(succeeded).append(" succeeded, ").append(failed).append(" failed\n");
161
162        return sb.toString();
163    }
164
165    /**
166     * Builds aggregated output for nodeGroup and all children.
167     */
168    private String buildAggregatedOutput() throws Exception {
169        List<String> sources = queryDistinctSources();
170        if (sources.isEmpty()) {
171            return "";
172        }
173
174        StringBuilder sb = new StringBuilder();
175        sb.append("[Transition History: nodeGroup (with children)]\n");
176
177        int totalTransitions = 0;
178        int totalSucceeded = 0;
179        int totalFailed = 0;
180
181        for (String source : sources) {
182            List<TransitionEntry> entries = queryTransitions(source);
183            if (entries.isEmpty()) continue;
184
185            sb.append("\n  [").append(source).append("]\n");
186
187            for (TransitionEntry entry : entries) {
188                sb.append("  ").append(entry.success ? "o " : "x ");
189                sb.append("[").append(entry.timestamp).append("] ");
190                sb.append(entry.label);
191                if (entry.note != null && !entry.note.isEmpty()) {
192                    sb.append(" [").append(entry.note).append("]");
193                }
194                if (!entry.success && entry.errorMessage != null && !entry.errorMessage.isEmpty()) {
195                    sb.append(" ").append(entry.errorMessage);
196                }
197                sb.append("\n");
198
199                totalTransitions++;
200                if (entry.success) {
201                    totalSucceeded++;
202                } else {
203                    totalFailed++;
204                }
205            }
206        }
207
208        sb.append("\nSummary: ").append(totalTransitions).append(" transitions, ");
209        sb.append(totalSucceeded).append(" succeeded, ").append(totalFailed).append(" failed\n");
210
211        return sb.toString();
212    }
213
214    /**
215     * Queries transition logs for a specific actor.
216     */
217    private List<TransitionEntry> queryTransitions(String source) throws Exception {
218        List<TransitionEntry> entries = new ArrayList<>();
219
220        String sql = "SELECT timestamp, label, level, message FROM logs " +
221                     "WHERE session_id = ? AND node_id = ? AND message LIKE '%Transition %' " +
222                     "ORDER BY timestamp";
223
224        try (PreparedStatement ps = connection.prepareStatement(sql)) {
225            ps.setLong(1, sessionId);
226            ps.setString(2, source);
227
228            try (ResultSet rs = ps.executeQuery()) {
229                while (rs.next()) {
230                    Timestamp ts = rs.getTimestamp("timestamp");
231                    String label = rs.getString("label");
232                    String message = rs.getString("message");
233
234                    boolean success = message.contains("SUCCESS");
235                    String errorMessage = null;
236                    if (!success) {
237                        int dashIdx = message.indexOf(" - ");
238                        if (dashIdx > 0) {
239                            errorMessage = message.substring(dashIdx + 3);
240                        }
241                    }
242
243                    String[] transitionAndNote = extractTransitionAndNote(message);
244                    String transition = transitionAndNote[0];
245                    String note = transitionAndNote[1];
246
247                    String displayLabel = (label != null && label.contains("->")) ? label : transition;
248                    String timeStr = ts.toLocalDateTime().format(TIME_FORMAT);
249
250                    entries.add(new TransitionEntry(timeStr, displayLabel, note, success, errorMessage));
251                }
252            }
253        }
254
255        return entries;
256    }
257
258    /**
259     * Queries distinct actor sources that have transition logs.
260     */
261    private List<String> queryDistinctSources() throws Exception {
262        List<String> sources = new ArrayList<>();
263
264        String sql = "SELECT DISTINCT node_id FROM logs " +
265                     "WHERE session_id = ? AND message LIKE '%Transition %' " +
266                     "ORDER BY node_id";
267
268        try (PreparedStatement ps = connection.prepareStatement(sql)) {
269            ps.setLong(1, sessionId);
270
271            try (ResultSet rs = ps.executeQuery()) {
272                while (rs.next()) {
273                    sources.add(rs.getString("node_id"));
274                }
275            }
276        }
277
278        return sources;
279    }
280
281    /**
282     * Extracts transition and note from message.
283     *
284     * @return String[2] where [0]=transition, [1]=note
285     */
286    private String[] extractTransitionAndNote(String message) {
287        if (message == null) return new String[]{"unknown", ""};
288
289        int idx = message.indexOf("Transition ");
290        if (idx < 0) return new String[]{"unknown", ""};
291
292        String afterTransition = message.substring(idx + "Transition ".length());
293        int colonIdx = afterTransition.indexOf(": ");
294        if (colonIdx < 0) return new String[]{"unknown", ""};
295
296        String statesPart = afterTransition.substring(colonIdx + 2);
297
298        // Extract note [xxx]
299        String note = "";
300        int bracketStart = statesPart.indexOf(" [");
301        int bracketEnd = statesPart.indexOf("]");
302        if (bracketStart > 0 && bracketEnd > bracketStart) {
303            note = statesPart.substring(bracketStart + 2, bracketEnd);
304            statesPart = statesPart.substring(0, bracketStart);
305        }
306
307        // Remove " - error message" if present
308        int dashIdx = statesPart.indexOf(" - ");
309        if (dashIdx > 0) {
310            statesPart = statesPart.substring(0, dashIdx);
311        }
312
313        return new String[]{statesPart.trim(), note};
314    }
315
316    @Override
317    public String getTitle() {
318        return null;  // Title is embedded in content
319    }
320
321    /**
322     * Internal class to hold transition entry data.
323     */
324    private static class TransitionEntry {
325        final String timestamp;
326        final String label;
327        final String note;
328        final boolean success;
329        final String errorMessage;
330
331        TransitionEntry(String timestamp, String label, String note, boolean success, String errorMessage) {
332            this.timestamp = timestamp;
333            this.label = label;
334            this.note = note;
335            this.success = success;
336            this.errorMessage = errorMessage;
337        }
338    }
339}