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;
019
020import com.scivicslab.actoriac.log.DistributedLogStore;
021import com.scivicslab.pojoactor.core.ActionResult;
022import com.scivicslab.pojoactor.core.CallableByActionName;
023import com.scivicslab.pojoactor.workflow.ActorSystemAware;
024import com.scivicslab.pojoactor.workflow.IIActorRef;
025import com.scivicslab.pojoactor.workflow.IIActorSystem;
026
027import org.json.JSONObject;
028import org.yaml.snakeyaml.Yaml;
029
030import java.io.*;
031import java.nio.file.*;
032import java.sql.*;
033import java.util.*;
034import java.util.logging.Level;
035import java.util.logging.Logger;
036
037/**
038 * Workflow execution reporter for actor-IaC.
039 *
040 * <p>Aggregates workflow execution results and generates final reports.
041 * Messages starting with '%' prefix are collected and displayed in the
042 * final report, enabling simple check/status reporting from workflows.</p>
043 *
044 * <h2>Usage in workflows:</h2>
045 * <pre>
046 * - actor: this
047 *   method: executeCommand
048 *   arguments:
049 *     - |
050 *       if command -v node > /dev/null; then
051 *         echo "%[OK] Node.js: $(node --version)"
052 *       else
053 *         echo "%[ERROR] Node.js: not found"
054 *       fi
055 * </pre>
056 *
057 * <h2>Actions:</h2>
058 * <ul>
059 *   <li>{@code report} - Generate workflow execution report. Args: sessionId (optional)</li>
060 *   <li>{@code transition-summary} - Show transition success/failure summary. Args: sessionId</li>
061 * </ul>
062 *
063 * <h2>Report Output Example:</h2>
064 * <pre>
065 * === Workflow Execution Report ===
066 * Session #42 | Workflow: DocumentDeployWorkflow | Status: COMPLETED
067 *
068 * --- Check Results ---
069 * [OK] Node.js: v18.0.0
070 * [OK] yarn: 1.22.19
071 * [ERROR] Maven: not found
072 *
073 * --- Transitions ---
074 * [✓] check-doclist
075 * [✓] detect-changes
076 * [✗] build-docs: yarn not found
077 *
078 * === Result: FAILED ===
079 * </pre>
080 *
081 * @author devteam@scivicslab.com
082 * @since 2.12.2
083 */
084public class WorkflowReporter implements CallableByActionName, ActorSystemAware {
085
086    private static final String CLASS_NAME = WorkflowReporter.class.getName();
087    private static final Logger logger = Logger.getLogger(CLASS_NAME);
088
089    /** Prefix for messages to be included in the final report. */
090    private static final String REPORT_PREFIX = "%";
091
092    private Connection connection;
093    private IIActorSystem system;
094
095    /** Pre-collected lines to be added at the beginning of the report. */
096    private final List<String> preLines = new ArrayList<>();
097
098    /**
099     * Sets the database connection for log queries.
100     *
101     * @param connection the JDBC connection to the H2 log database
102     */
103    public void setConnection(Connection connection) {
104        this.connection = connection;
105    }
106
107    @Override
108    public void setActorSystem(IIActorSystem system) {
109        logger.entering(CLASS_NAME, "setActorSystem", system);
110        this.system = system;
111
112        // Auto-initialize database connection from DistributedLogStore singleton
113        if (this.connection == null) {
114            DistributedLogStore logStore = DistributedLogStore.getInstance();
115            if (logStore != null) {
116                this.connection = logStore.getConnection();
117                logger.info("WorkflowReporter: Auto-initialized database connection from DistributedLogStore");
118            } else {
119                logger.warning("WorkflowReporter: DistributedLogStore singleton not available");
120            }
121        }
122
123        logger.exiting(CLASS_NAME, "setActorSystem");
124    }
125
126    @Override
127    public ActionResult callByActionName(String actionName, String args) {
128        logger.info("WorkflowReporter.callByActionName: actionName=" + actionName + ", args=" + args);
129        try {
130            ActionResult result = switch (actionName) {
131                case "report" -> generateReport(args);
132                case "transition-summary" -> transitionSummary(args);
133                case "addLine" -> addLine(args);
134                case "addWorkflowInfo" -> addWorkflowInfo(args);
135                default -> {
136                    logger.warning("WorkflowReporter: Unknown action: " + actionName);
137                    yield new ActionResult(false, "Unknown action: " + actionName);
138                }
139            };
140            logger.info("WorkflowReporter.callByActionName: result=" + result.isSuccess());
141            return result;
142        } catch (Exception e) {
143            ActionResult errorResult = new ActionResult(false, "Error: " + e.getMessage());
144            logger.logp(Level.WARNING, CLASS_NAME, "callByActionName", "Exception occurred", e);
145            return errorResult;
146        }
147    }
148
149    /**
150     * Add a line to the report.
151     *
152     * <p>Lines added via this method will appear at the beginning of the
153     * "Check Results" section in the report. This is useful for adding
154     * workflow description or other contextual information.</p>
155     *
156     * <p>The argument is passed as a JSON array from the workflow engine,
157     * so this method extracts the first element.</p>
158     *
159     * @param args the JSON array containing the line to add
160     * @return ActionResult indicating success
161     */
162    private ActionResult addLine(String args) {
163        logger.info("WorkflowReporter.addLine called with args: " + args);
164        try {
165            // Parse the JSON array to extract the actual line
166            org.json.JSONArray jsonArray = new org.json.JSONArray(args);
167            if (jsonArray.length() > 0) {
168                String line = jsonArray.getString(0);
169                preLines.add(line);
170                logger.info("WorkflowReporter.addLine: added line: " + line);
171            }
172            return new ActionResult(true, "Line added");
173        } catch (Exception e) {
174            logger.warning("WorkflowReporter.addLine: exception: " + e.getMessage());
175            // Fallback: treat args as plain string
176            if (args != null && !args.isEmpty() && !args.equals("[]")) {
177                preLines.add(args);
178            }
179            return new ActionResult(true, "Line added (fallback)");
180        }
181    }
182
183    /**
184     * Add workflow metadata (file, name, description) to the report.
185     *
186     * <p>This action reads the workflow YAML file and extracts the name and
187     * description fields, adding them to the report header. The workflow
188     * file path is obtained from nodeGroup.</p>
189     *
190     * @param args unused (workflow path is obtained from nodeGroup)
191     * @return ActionResult indicating success or failure
192     */
193    private ActionResult addWorkflowInfo(String args) {
194        logger.info("WorkflowReporter.addWorkflowInfo called");
195
196        try {
197            // Get workflow path from nodeGroup
198            String workflowPath = getWorkflowPathFromNodeGroup();
199            if (workflowPath == null) {
200                logger.warning("WorkflowReporter.addWorkflowInfo: could not get workflow path from nodeGroup");
201                return new ActionResult(false, "Could not retrieve workflow path from nodeGroup");
202            }
203            logger.info("WorkflowReporter.addWorkflowInfo: workflowPath=" + workflowPath);
204
205            // Read and parse the YAML file
206            Path path = Paths.get(workflowPath);
207            if (!Files.exists(path)) {
208                // Try relative to current working directory
209                path = Paths.get(System.getProperty("user.dir"), workflowPath);
210            }
211            if (!Files.exists(path)) {
212                // Workflow info without reading file - use only database info
213                preLines.add("[Workflow Info]");
214                preLines.add("  File: " + workflowPath);
215                logger.info("WorkflowReporter.addWorkflowInfo: file not found, using DB info only");
216                return new ActionResult(true, "Workflow info added (file not found, DB info only)");
217            }
218
219            Map<String, Object> yaml;
220            try (InputStream is = Files.newInputStream(path)) {
221                Yaml yamlParser = new Yaml();
222                yaml = yamlParser.load(is);
223            }
224
225            if (yaml == null) {
226                return new ActionResult(false, "Failed to parse workflow YAML");
227            }
228
229            // Extract name and description
230            String name = (String) yaml.get("name");
231            Object descObj = yaml.get("description");
232            String description = descObj != null ? descObj.toString().trim() : null;
233
234            // Add to preLines
235            preLines.add("[Workflow Info]");
236            preLines.add("  File: " + workflowPath);
237            if (name != null) {
238                preLines.add("  Name: " + name);
239            }
240            if (description != null) {
241                preLines.add("");
242                preLines.add("[Description]");
243                // Indent each line of description
244                for (String line : description.split("\n")) {
245                    preLines.add("  " + line.trim());
246                }
247            }
248
249            logger.info("WorkflowReporter.addWorkflowInfo: added workflow info for " + workflowPath);
250            return new ActionResult(true, "Workflow info added");
251
252        } catch (Exception e) {
253            logger.warning("WorkflowReporter.addWorkflowInfo: exception: " + e.getMessage());
254            return new ActionResult(false, "Failed to add workflow info: " + e.getMessage());
255        }
256    }
257
258    /**
259     * Retrieves workflow file path from session in database.
260     */
261    private String getWorkflowPathFromSession(long sessionId) throws SQLException {
262        String sql = "SELECT workflow_name FROM sessions WHERE id = ?";
263        try (PreparedStatement ps = connection.prepareStatement(sql)) {
264            ps.setLong(1, sessionId);
265            try (ResultSet rs = ps.executeQuery()) {
266                if (rs.next()) {
267                    return rs.getString("workflow_name");
268                }
269            }
270        }
271        return null;
272    }
273
274    /**
275     * Retrieves workflow file path from nodeGroup actor.
276     */
277    private String getWorkflowPathFromNodeGroup() {
278        if (system == null) {
279            return null;
280        }
281        IIActorRef<?> nodeGroup = system.getIIActor("nodeGroup");
282        if (nodeGroup == null) {
283            return null;
284        }
285        ActionResult result = nodeGroup.callByActionName("getWorkflowPath", "");
286        return result.isSuccess() ? result.getResult() : null;
287    }
288
289    /**
290     * Generate workflow execution report.
291     *
292     * <p>Collects messages starting with '%' prefix and generates a summary report.</p>
293     *
294     * @param sessionIdStr session ID to report on, or empty for auto-retrieval
295     */
296    private ActionResult generateReport(String sessionIdStr) {
297        logger.entering(CLASS_NAME, "generateReport", sessionIdStr);
298        if (connection == null) {
299            return new ActionResult(false, "Not connected. Database connection not set.");
300        }
301
302        try {
303            long sessionId = resolveSessionId(sessionIdStr);
304
305            StringBuilder sb = new StringBuilder();
306            sb.append("=== Workflow Execution Report ===\n");
307
308            // Get session info
309            String sessionInfo = getSessionInfo(sessionId);
310            if (sessionInfo != null) {
311                sb.append(sessionInfo).append("\n");
312            }
313
314            // Add pre-collected lines first
315            if (!preLines.isEmpty()) {
316                sb.append("\n");
317                for (String line : preLines) {
318                    sb.append(line).append("\n");
319                }
320            }
321
322            // Get messages with % prefix
323            List<String> reportMessages = getReportMessages(sessionId);
324            if (!reportMessages.isEmpty()) {
325                sb.append("\n--- Check Results ---\n");
326                for (String msg : reportMessages) {
327                    sb.append(msg).append("\n");
328                }
329            }
330
331            // Get transition summary
332            String transitionInfo = buildTransitionSummary(sessionId);
333            if (transitionInfo != null) {
334                sb.append("\n--- Transitions ---\n");
335                sb.append(transitionInfo);
336            }
337
338            String result = sb.toString();
339            reportToMultiplexer(result);
340            return new ActionResult(true, result);
341
342        } catch (Exception e) {
343            return new ActionResult(false, "Report generation failed: " + e.getMessage());
344        }
345    }
346
347    /**
348     * Get session information.
349     */
350    private String getSessionInfo(long sessionId) throws SQLException {
351        String sql = "SELECT workflow_name, overlay_name, status, started_at, ended_at " +
352                     "FROM sessions WHERE id = ?";
353        try (PreparedStatement ps = connection.prepareStatement(sql)) {
354            ps.setLong(1, sessionId);
355            try (ResultSet rs = ps.executeQuery()) {
356                if (rs.next()) {
357                    String workflow = rs.getString("workflow_name");
358                    String overlay = rs.getString("overlay_name");
359                    String status = rs.getString("status");
360                    Timestamp started = rs.getTimestamp("started_at");
361                    Timestamp ended = rs.getTimestamp("ended_at");
362
363                    StringBuilder sb = new StringBuilder();
364                    sb.append("Session #").append(sessionId);
365                    if (workflow != null) sb.append(" | Workflow: ").append(workflow);
366                    if (overlay != null) sb.append(" | Overlay: ").append(overlay);
367                    if (started != null) sb.append("\nStarted: ").append(started);
368                    if (ended != null) sb.append(" | Ended: ").append(ended);
369                    return sb.toString();
370                }
371            }
372        }
373        return null;
374    }
375
376    /**
377     * Get messages with % prefix from logs.
378     */
379    private List<String> getReportMessages(long sessionId) throws SQLException {
380        List<String> messages = new ArrayList<>();
381
382        String sql = "SELECT message FROM logs WHERE session_id = ? ORDER BY timestamp";
383        try (PreparedStatement ps = connection.prepareStatement(sql)) {
384            ps.setLong(1, sessionId);
385            try (ResultSet rs = ps.executeQuery()) {
386                while (rs.next()) {
387                    String message = rs.getString("message");
388                    if (message != null) {
389                        // Extract lines starting with %
390                        for (String line : message.split("\n")) {
391                            String trimmed = line.trim();
392                            // Handle prefixes like [node-xxx] %message
393                            String cleaned = trimmed.replaceFirst("^\\[node-[^\\]]+\\]\\s*", "");
394                            if (cleaned.startsWith(REPORT_PREFIX)) {
395                                messages.add(cleaned.substring(1).trim());
396                            }
397                        }
398                    }
399                }
400            }
401        }
402        // Remove duplicates while preserving order, then sort by hostname
403        List<String> uniqueMessages = new ArrayList<>(new java.util.LinkedHashSet<>(messages));
404        uniqueMessages.sort(null);
405        return uniqueMessages;
406    }
407
408    /**
409     * Build transition summary.
410     */
411    private String buildTransitionSummary(long sessionId) throws SQLException {
412        String sql = "SELECT label, level, message FROM logs " +
413                     "WHERE session_id = ? AND message LIKE 'Transition %' " +
414                     "ORDER BY timestamp";
415
416        StringBuilder sb = new StringBuilder();
417        int success = 0;
418        int failed = 0;
419
420        try (PreparedStatement ps = connection.prepareStatement(sql)) {
421            ps.setLong(1, sessionId);
422            try (ResultSet rs = ps.executeQuery()) {
423                while (rs.next()) {
424                    String label = rs.getString("label");
425                    String level = rs.getString("level");
426                    String message = rs.getString("message");
427
428                    boolean isSuccess = message.contains("SUCCESS");
429                    if (isSuccess) {
430                        success++;
431                        sb.append("[✓] ").append(label != null ? label : "unknown").append("\n");
432                    } else {
433                        failed++;
434                        sb.append("[✗] ").append(label != null ? label : "unknown");
435                        // Extract failure reason
436                        int dashIdx = message.indexOf(" - ");
437                        if (dashIdx > 0) {
438                            sb.append(": ").append(message.substring(dashIdx + 3));
439                        }
440                        sb.append("\n");
441                    }
442                }
443            }
444        }
445
446        if (success == 0 && failed == 0) {
447            return null;
448        }
449
450        sb.append("\nSummary: ").append(success).append(" succeeded, ").append(failed).append(" failed");
451        return sb.toString();
452    }
453
454    /**
455     * Get final session status.
456     */
457    private String getFinalStatus(long sessionId) throws SQLException {
458        String sql = "SELECT status FROM sessions WHERE id = ?";
459        try (PreparedStatement ps = connection.prepareStatement(sql)) {
460            ps.setLong(1, sessionId);
461            try (ResultSet rs = ps.executeQuery()) {
462                if (rs.next()) {
463                    String status = rs.getString("status");
464                    if ("COMPLETED".equals(status)) {
465                        return "=== Result: SUCCESS ===";
466                    } else if ("FAILED".equals(status)) {
467                        return "=== Result: FAILED ===";
468                    } else {
469                        return "=== Result: " + status + " ===";
470                    }
471                }
472            }
473        }
474        return "=== Result: UNKNOWN ===";
475    }
476
477    /**
478     * Show transition success/failure summary.
479     */
480    private ActionResult transitionSummary(String sessionIdStr) {
481        logger.entering(CLASS_NAME, "transitionSummary", sessionIdStr);
482        if (connection == null) {
483            return new ActionResult(false, "Not connected. Database connection not set.");
484        }
485
486        try {
487            long sessionId = resolveSessionId(sessionIdStr);
488            String summary = buildTransitionSummary(sessionId);
489            if (summary == null) {
490                summary = "No transition data found for session " + sessionId;
491            }
492            reportToMultiplexer(summary);
493            return new ActionResult(true, summary);
494        } catch (Exception e) {
495            return new ActionResult(false, "Query failed: " + e.getMessage());
496        }
497    }
498
499    /**
500     * Resolve session ID from argument or auto-retrieve from nodeGroup.
501     */
502    private long resolveSessionId(String sessionIdStr) throws NumberFormatException {
503        if (sessionIdStr == null || sessionIdStr.trim().isEmpty() || sessionIdStr.equals("[]")) {
504            String autoSessionId = getSessionIdFromNodeGroup();
505            if (autoSessionId == null) {
506                throw new NumberFormatException("Session ID not specified and could not retrieve from nodeGroup");
507            }
508            return Long.parseLong(autoSessionId);
509        }
510        return Long.parseLong(sessionIdStr.trim());
511    }
512
513    /**
514     * Retrieves session ID from nodeGroup actor.
515     */
516    private String getSessionIdFromNodeGroup() {
517        if (system == null) {
518            return null;
519        }
520        IIActorRef<?> nodeGroup = system.getIIActor("nodeGroup");
521        if (nodeGroup == null) {
522            return null;
523        }
524        ActionResult result = nodeGroup.callByActionName("getSessionId", "");
525        return result.isSuccess() ? result.getResult() : null;
526    }
527
528    /**
529     * Report result to outputMultiplexer.
530     */
531    private void reportToMultiplexer(String data) {
532        if (system == null) {
533            return;
534        }
535
536        IIActorRef<?> multiplexer = system.getIIActor("outputMultiplexer");
537        if (multiplexer == null) {
538            return;
539        }
540
541        JSONObject arg = new JSONObject();
542        arg.put("source", "workflow-reporter");
543        arg.put("type", "plugin-result");
544        arg.put("data", data);
545        multiplexer.callByActionName("add", arg.toString());
546    }
547}