001/*
002 * Copyright 2025 devteam@scivics-lab.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 java.util.ArrayList;
021import java.util.List;
022import java.util.concurrent.ExecutionException;
023import java.util.logging.Level;
024import java.util.logging.Logger;
025import java.util.regex.Pattern;
026
027import org.json.JSONArray;
028import org.json.JSONObject;
029
030import com.scivicslab.pojoactor.core.ActionResult;
031import com.scivicslab.pojoactor.core.accumulator.Accumulator;
032import com.scivicslab.pojoactor.core.accumulator.BufferedAccumulator;
033import com.scivicslab.pojoactor.core.accumulator.JsonAccumulator;
034import com.scivicslab.pojoactor.core.accumulator.StreamingAccumulator;
035import com.scivicslab.pojoactor.core.accumulator.TableAccumulator;
036import com.scivicslab.pojoactor.workflow.IIActorRef;
037import com.scivicslab.pojoactor.workflow.IIActorSystem;
038
039import com.scivicslab.actoriac.log.DistributedLogStore;
040
041/**
042 * Interpreter-interfaced actor reference for {@link NodeGroupInterpreter} instances.
043 *
044 * <p>This class provides a concrete implementation of {@link IIActorRef}
045 * specifically for {@link NodeGroupInterpreter} objects. It manages groups of infrastructure
046 * nodes and can apply actions to all nodes in a group using wildcard patterns.</p>
047 *
048 * <p>NodeGroupInterpreter extends Interpreter, so this class can execute main workflows
049 * that orchestrate multiple nodes.</p>
050 *
051 * <p><strong>Supported actions:</strong></p>
052 * <p><em>Workflow actions (from Interpreter):</em></p>
053 * <ul>
054 *   <li>{@code runWorkflow} - Loads and runs a workflow file</li>
055 *   <li>{@code readYaml} - Reads a YAML workflow definition</li>
056 *   <li>{@code runUntilEnd} - Executes the workflow until completion</li>
057 * </ul>
058 * <p><em>NodeGroup actions:</em></p>
059 * <ul>
060 *   <li>{@code hasInventory} - Returns true if inventory is loaded (for conditional branching)</li>
061 *   <li>{@code createNodeActors} - Creates child actors for all nodes in a specified group</li>
062 *   <li>{@code apply} - Applies an action to child actors matching a wildcard pattern</li>
063 *   <li>{@code hasAccumulator} - Returns true if accumulator exists (for idempotent workflows)</li>
064 *   <li>{@code createAccumulator} - Creates an accumulator for result collection</li>
065 *   <li>{@code getAccumulatorSummary} - Gets the collected results</li>
066 * </ul>
067 *
068 * <p><strong>Node Actor Hierarchy:</strong></p>
069 * <p>When {@code createNodeActors} is called, it creates a parent-child relationship:</p>
070 * <pre>
071 * NodeGroup (parent)
072 *   ├─ node-web-01 (child NodeIIAR)
073 *   ├─ node-web-02 (child NodeIIAR)
074 *   └─ node-db-01 (child NodeIIAR)
075 * </pre>
076 *
077 * <p><strong>Example YAML Workflow:</strong></p>
078 * <pre>{@code
079 * name: setup-nodegroup
080 * steps:
081 *   # Step 1: Create node actors
082 *   - states: [0, 1]
083 *     actions:
084 *       - actor: nodeGroup
085 *         method: createNodeActors
086 *         arguments: ["web-servers"]
087 *
088 *   # Step 2: Run workflow on all nodes (load and execute in one step)
089 *   - states: [1, end]
090 *     actions:
091 *       - actor: nodeGroup
092 *         method: apply
093 *         arguments: ['{"actor": "node-*", "method": "runWorkflow", "arguments": ["deploy.yaml"]}']
094 * }</pre>
095 *
096 * @author devteam@scivics-lab.com
097 */
098public class NodeGroupIIAR extends IIActorRef<NodeGroupInterpreter> {
099
100    Logger logger = null;
101    private LoggingAccumulatorIIAR accumulatorActor = null;
102
103    /**
104     * Constructs a new NodeGroupIIAR with the specified actor name and nodeGroupInterpreter object.
105     *
106     * @param actorName the name of this actor
107     * @param object the {@link NodeGroupInterpreter} instance managed by this actor reference
108     */
109    public NodeGroupIIAR(String actorName, NodeGroupInterpreter object) {
110        super(actorName, object);
111        logger = Logger.getLogger(actorName);
112    }
113
114    /**
115     * Constructs a new NodeGroupIIAR with the specified actor name, nodeGroupInterpreter object,
116     * and actor system.
117     *
118     * @param actorName the name of this actor
119     * @param object the {@link NodeGroupInterpreter} instance managed by this actor reference
120     * @param system the actor system managing this actor
121     */
122    public NodeGroupIIAR(String actorName, NodeGroupInterpreter object, IIActorSystem system) {
123        super(actorName, object, system);
124        logger = Logger.getLogger(actorName);
125
126        // Set the selfActorRef in the Interpreter (NodeGroupInterpreter extends Interpreter)
127        object.setSelfActorRef(this);
128    }
129
130    /**
131     * Invokes an action on the node group by name with the given arguments.
132     *
133     * <p>This method dispatches to specialized handler methods based on the action type:</p>
134     * <ul>
135     *   <li>Workflow actions: {@link #handleWorkflowAction}</li>
136     *   <li>NodeGroup-specific actions: {@link #handleNodeGroupAction}</li>
137     * </ul>
138     *
139     * @param actionName the name of the action to execute
140     * @param arg the argument string (JSON array format)
141     * @return an {@link ActionResult} indicating success or failure with a message
142     */
143    @Override
144    public ActionResult callByActionName(String actionName, String arg) {
145        logger.fine(String.format("actionName = %s, args = %s", actionName, arg));
146
147        try {
148            // Workflow execution actions (from Interpreter)
149            ActionResult workflowResult = handleWorkflowAction(actionName, arg);
150            if (workflowResult != null) {
151                return workflowResult;
152            }
153
154            // NodeGroup-specific actions
155            ActionResult nodeGroupResult = handleNodeGroupAction(actionName, arg);
156            if (nodeGroupResult != null) {
157                return nodeGroupResult;
158            }
159
160            // Unknown action
161            logger.log(Level.SEVERE, String.format("Unknown action: actorName = %s, action = %s, arg = %s",
162                    this.getName(), actionName, arg));
163            return new ActionResult(false, "Unknown action: " + actionName);
164
165        } catch (InterruptedException e) {
166            logger.log(Level.SEVERE, String.format("actionName = %s, args = %s", actionName, arg), e);
167            return new ActionResult(false, "Interrupted: " + e.getMessage());
168        } catch (ExecutionException e) {
169            logger.log(Level.SEVERE, String.format("actionName = %s, args = %s", actionName, arg), e);
170            return new ActionResult(false, "Execution error: " + e.getMessage());
171        } catch (Exception e) {
172            logger.log(Level.SEVERE, String.format("actionName = %s, args = %s", actionName, arg), e);
173            return new ActionResult(false, "Error: " + e.getMessage());
174        }
175    }
176
177    /**
178     * Handles workflow-related actions (from Interpreter).
179     *
180     * @param actionName the action name
181     * @param arg the argument string
182     * @return ActionResult if handled, null if not a workflow action
183     */
184    private ActionResult handleWorkflowAction(String actionName, String arg)
185            throws InterruptedException, ExecutionException {
186
187        switch (actionName) {
188            case "execCode":
189                return this.ask(n -> n.execCode()).get();
190
191            case "runUntilEnd":
192                int maxIterations = parseMaxIterations(arg, 10000);
193                return this.ask(n -> n.runUntilEnd(maxIterations)).get();
194
195            case "runWorkflow":
196                JSONArray runArgs = new JSONArray(arg);
197                String workflowFile = runArgs.getString(0);
198                int runMaxIterations = runArgs.length() > 1 ? runArgs.getInt(1) : 10000;
199                logger.info(String.format("Running workflow: %s (maxIterations=%d)", workflowFile, runMaxIterations));
200                ActionResult result = this.object.runWorkflow(workflowFile, runMaxIterations);
201                logger.info(String.format("Workflow completed: success=%s, result=%s", result.isSuccess(), result.getResult()));
202                return result;
203
204            case "readYaml":
205                return handleReadYaml(arg);
206
207            default:
208                return null; // Not a workflow action
209        }
210    }
211
212    /**
213     * Handles NodeGroup-specific actions.
214     *
215     * @param actionName the action name
216     * @param arg the argument string
217     * @return ActionResult if handled, null if not a NodeGroup action
218     */
219    private ActionResult handleNodeGroupAction(String actionName, String arg)
220            throws InterruptedException, ExecutionException {
221
222        switch (actionName) {
223            case "hasInventory":
224                boolean hasInv = this.object.getInventory() != null;
225                return new ActionResult(hasInv, hasInv ? "Inventory available" : "No inventory");
226
227            case "createNodeActors":
228                String groupName = extractSingleArgument(arg);
229                createNodeActors(groupName);
230                return new ActionResult(true, String.format("Created node actors for group '%s'", groupName));
231
232            case "apply":
233                return apply(arg);
234
235            case "executeCommandOnAllNodes":
236                String command = extractSingleArgument(arg);
237                List<String> results = executeCommandOnAllNodes(command);
238                return new ActionResult(true,
239                    String.format("Executed command on %d nodes: %s", results.size(), results));
240
241            case "hasAccumulator":
242                boolean hasAcc = accumulatorActor != null;
243                return new ActionResult(hasAcc, hasAcc ? "Accumulator exists" : "No accumulator");
244
245            case "createAccumulator":
246                String type = extractSingleArgument(arg);
247                createAccumulator(type);
248                return new ActionResult(true, String.format("Created %s accumulator", type));
249
250            case "getAccumulatorSummary":
251                return getAccumulatorSummary();
252
253            case "printSessionSummary":
254                return printSessionSummary();
255
256            case "doNothing":
257                return new ActionResult(true, arg);
258
259            default:
260                return null; // Not a NodeGroup action
261        }
262    }
263
264    // --- Helper methods ---
265
266    private ActionResult handleReadYaml(String arg) throws InterruptedException, ExecutionException {
267        String filePath = extractSingleArgument(arg);
268        try {
269            String overlayPath = this.object.getOverlayDir();
270            if (overlayPath != null) {
271                java.nio.file.Path yamlPath = java.nio.file.Path.of(filePath);
272                java.nio.file.Path overlayDir = java.nio.file.Path.of(overlayPath);
273                this.tell(n -> {
274                    try {
275                        n.readYaml(yamlPath, overlayDir);
276                    } catch (java.io.IOException e) {
277                        throw new RuntimeException(e);
278                    }
279                }).get();
280                return new ActionResult(true, "YAML loaded with overlay: " + overlayPath);
281            } else {
282                try (java.io.InputStream input = new java.io.FileInputStream(new java.io.File(filePath))) {
283                    this.tell(n -> n.readYaml(input)).get();
284                    return new ActionResult(true, "YAML loaded successfully");
285                }
286            }
287        } catch (java.io.FileNotFoundException e) {
288            logger.log(Level.SEVERE, String.format("file not found: %s", filePath), e);
289            return new ActionResult(false, "File not found: " + filePath);
290        } catch (java.io.IOException e) {
291            logger.log(Level.SEVERE, String.format("IOException: %s", filePath), e);
292            return new ActionResult(false, "IO error: " + filePath);
293        } catch (RuntimeException e) {
294            if (e.getCause() instanceof java.io.IOException) {
295                logger.log(Level.SEVERE, String.format("IOException: %s", filePath), e.getCause());
296                return new ActionResult(false, "IO error: " + filePath);
297            }
298            throw e;
299        }
300    }
301
302    private int parseMaxIterations(String arg, int defaultValue) {
303        if (arg != null && !arg.isEmpty() && !arg.equals("[]")) {
304            try {
305                JSONArray args = new JSONArray(arg);
306                if (args.length() > 0) {
307                    return args.getInt(0);
308                }
309            } catch (Exception e) {
310                // Use default if parsing fails
311            }
312        }
313        return defaultValue;
314    }
315
316    /**
317     * Creates child node actors for all nodes in the specified group.
318     *
319     * <p>This method creates Node POJOs using the NodeGroup's inventory,
320     * wraps each in a NodeInterpreter (for workflow capabilities),
321     * then wraps in a NodeIIAR, and registers them as children of this actor
322     * using the parent-child relationship mechanism.</p>
323     *
324     * <p>Special handling for "local" group: creates a localhost node without
325     * requiring an inventory file. This is useful for development and testing.</p>
326     *
327     * @param groupName the name of the group from the inventory file, or "local" for localhost
328     */
329    private void createNodeActors(String groupName) {
330        // Direct execution (no tell().get() to avoid deadlock when called from workflow)
331        IIActorSystem sys = (IIActorSystem) this.system();
332        NodeGroupInterpreter nodeGroupInterpreter = this.object;
333
334        // Create Node POJOs for the group
335        // Special handling for "local" group: create localhost node without inventory
336        List<Node> nodes;
337        if ("local".equals(groupName)) {
338            nodes = nodeGroupInterpreter.createLocalNode();
339        } else {
340            nodes = nodeGroupInterpreter.createNodesForGroup(groupName);
341        }
342
343        // Create child actors for each node
344        for (Node node : nodes) {
345            String nodeName = "node-" + node.getHostname();
346
347            // Wrap Node in NodeInterpreter to add workflow capabilities
348            NodeInterpreter nodeInterpreter = new NodeInterpreter(node, sys);
349
350            // Propagate workflowBaseDir to child interpreter
351            if (nodeGroupInterpreter.getWorkflowBaseDir() != null) {
352                nodeInterpreter.setWorkflowBaseDir(nodeGroupInterpreter.getWorkflowBaseDir());
353            }
354
355            // Propagate overlayDir to child interpreter
356            if (nodeGroupInterpreter.getOverlayDir() != null) {
357                nodeInterpreter.setOverlayDir(nodeGroupInterpreter.getOverlayDir());
358            }
359
360            // Create child actor using ActorRef.createChild()
361            // This establishes parent-child relationship
362            this.createChild(nodeName, nodeInterpreter);
363
364            // Also wrap in NodeIIAR and add to system for workflow execution
365            NodeIIAR nodeActor = new NodeIIAR(nodeName, nodeInterpreter, sys);
366            sys.addIIActor(nodeActor);
367
368            logger.fine(String.format("Created child node actor: %s", nodeName));
369        }
370        logger.info(String.format("Created %d node actors for group '%s'", nodes.size(), groupName));
371    }
372
373    /**
374     * Applies an action to child actors matching a wildcard pattern.
375     *
376     * <p>This method parses an action definition JSON and executes the specified
377     * method on all child actors whose names match the pattern.</p>
378     *
379     * <p>Action definition format:</p>
380     * <pre>{@code
381     * {
382     *   "actor": "node-*",           // Wildcard pattern for actor names
383     *   "method": "executeCommand",  // Method to call
384     *   "arguments": ["ls -la"]      // Arguments (optional)
385     * }
386     * }</pre>
387     *
388     * <p>Supported wildcard patterns:</p>
389     * <ul>
390     * <li>{@code *} - Matches all child actors</li>
391     * <li>{@code node-*} - Matches actors starting with "node-"</li>
392     * <li>{@code *-web} - Matches actors ending with "-web"</li>
393     * </ul>
394     *
395     * @param actionDef JSON string defining the action to apply
396     * @return ActionResult indicating success or failure
397     */
398    private ActionResult apply(String actionDef) {
399        try {
400            JSONObject action = new JSONObject(actionDef);
401            String actorPattern = action.getString("actor");
402            String method = action.getString("method");
403            JSONArray argsArray = action.optJSONArray("arguments");
404            String args = argsArray != null ? argsArray.toString() : "[]";
405
406            // Find matching child actors
407            List<IIActorRef<?>> matchedActors = findMatchingChildActors(actorPattern);
408
409            if (matchedActors.isEmpty()) {
410                return new ActionResult(false, "No actors matched pattern: " + actorPattern);
411            }
412
413            logger.info(String.format("Applying method '%s' to %d actors matching '%s'",
414                method, matchedActors.size(), actorPattern));
415
416            // Apply action to each matching actor (continue on failure, report all errors)
417            int successCount = 0;
418            List<String> failures = new ArrayList<>();
419            for (IIActorRef<?> actor : matchedActors) {
420                ActionResult result = actor.callByActionName(method, args);
421                if (!result.isSuccess()) {
422                    failures.add(String.format("%s: %s", actor.getName(), result.getResult()));
423                    logger.warning(String.format("Failed on %s: %s", actor.getName(), result.getResult()));
424                } else {
425                    successCount++;
426                    logger.fine(String.format("Applied to %s: %s", actor.getName(), result.getResult()));
427                }
428            }
429
430            if (failures.isEmpty()) {
431                return new ActionResult(true,
432                    String.format("Applied to %d actors", successCount));
433            } else {
434                return new ActionResult(false,
435                    String.format("Applied to %d/%d actors. Failures: %s",
436                        successCount, matchedActors.size(), String.join("; ", failures)));
437            }
438
439        } catch (Exception e) {
440            logger.log(Level.SEVERE, "Error in apply: " + actionDef, e);
441            return new ActionResult(false, "Error: " + e.getMessage());
442        }
443    }
444
445    /**
446     * Finds child actors matching a wildcard pattern.
447     *
448     * @param pattern the wildcard pattern (e.g., "node-*", "*-web", "*")
449     * @return list of matching child actors
450     */
451    private List<IIActorRef<?>> findMatchingChildActors(String pattern) {
452        List<IIActorRef<?>> matched = new ArrayList<>();
453        IIActorSystem system = (IIActorSystem) this.system();
454
455        if (system == null) {
456            return matched;
457        }
458
459        List<String> childNames = new ArrayList<>(this.getNamesOfChildren());
460
461        // Exact match (no wildcard)
462        if (!pattern.contains("*")) {
463            if (childNames.contains(pattern)) {
464                IIActorRef<?> actor = system.getIIActor(pattern);
465                if (actor != null) {
466                    matched.add(actor);
467                }
468            }
469            return matched;
470        }
471
472        // Convert wildcard to regex
473        String regex = pattern
474            .replace(".", "\\.")
475            .replace("*", ".*");
476        Pattern compiled = Pattern.compile(regex);
477
478        for (String childName : childNames) {
479            if (compiled.matcher(childName).matches()) {
480                IIActorRef<?> actor = system.getIIActor(childName);
481                if (actor != null) {
482                    matched.add(actor);
483                }
484            }
485        }
486
487        return matched;
488    }
489
490    /**
491     * Executes a single command on all child node actors.
492     *
493     * @param command the command to execute
494     * @return list of results from each node
495     * @throws ExecutionException if command execution fails
496     * @throws InterruptedException if the operation is interrupted
497     */
498    private List<String> executeCommandOnAllNodes(String command)
499            throws ExecutionException, InterruptedException {
500
501        IIActorSystem system = (IIActorSystem) this.system();
502        List<String> results = new ArrayList<>();
503
504        // Get all child node names
505        List<String> childNames = new ArrayList<>(this.getNamesOfChildren());
506
507        logger.info(String.format("Executing command on %d nodes: %s", childNames.size(), command));
508
509        // Execute on each child node
510        for (String childName : childNames) {
511            IIActorRef<?> actorRef = system.getIIActor(childName);
512            if (actorRef == null || !(actorRef instanceof NodeIIAR)) {
513                logger.warning(String.format("Child node actor not found or wrong type: %s", childName));
514                continue;
515            }
516            NodeIIAR nodeActor = (NodeIIAR) actorRef;
517
518            // Execute the command
519            JSONArray commandArgs = new JSONArray();
520            commandArgs.put(command);
521            ActionResult result = nodeActor.callByActionName("executeCommand", commandArgs.toString());
522
523            results.add(String.format("%s: %s", childName, result.getResult()));
524        }
525
526        return results;
527    }
528
529    /**
530     * Extracts a single argument from JSON array format.
531     *
532     * @param arg the JSON array argument string
533     * @return the extracted argument
534     */
535    private String extractSingleArgument(String arg) {
536        try {
537            JSONArray jsonArray = new JSONArray(arg);
538            if (jsonArray.length() == 0) {
539                throw new IllegalArgumentException("Arguments cannot be empty");
540            }
541            return jsonArray.getString(0);
542        } catch (Exception e) {
543            throw new IllegalArgumentException(
544                "Invalid argument format. Expected JSON array: " + arg, e);
545        }
546    }
547
548    /**
549     * Creates an accumulator as a child actor.
550     *
551     * <p>If a log store is configured in the NodeGroupInterpreter, creates a
552     * LoggingAccumulatorIIAR that also writes to the H2 database.</p>
553     *
554     * @param type the accumulator type ("streaming", "buffered", "table", "json")
555     */
556    private void createAccumulator(String type) {
557        IIActorSystem sys = (IIActorSystem) this.system();
558
559        // Create POJO based on type
560        Accumulator accumulator;
561        switch (type.toLowerCase()) {
562            case "streaming":
563                accumulator = new StreamingAccumulator();
564                break;
565            case "buffered":
566                accumulator = new BufferedAccumulator();
567                break;
568            case "table":
569                accumulator = new TableAccumulator();
570                break;
571            case "json":
572                accumulator = new JsonAccumulator();
573                break;
574            default:
575                throw new IllegalArgumentException("Unknown accumulator type: " + type);
576        }
577
578        // Get logStore and sessionId from NodeGroupInterpreter
579        DistributedLogStore logStore = this.object.getLogStore();
580        long sessionId = this.object.getSessionId();
581
582        // Create LoggingAccumulatorIIAR that also writes to H2 database
583        accumulatorActor = new LoggingAccumulatorIIAR("accumulator", accumulator, sys, logStore, sessionId);
584        this.createChild("accumulator", accumulator);
585        sys.addIIActor(accumulatorActor);
586
587        logger.info(String.format("Created %s accumulator as child actor (logging=%s)",
588            type, logStore != null ? "enabled" : "disabled"));
589    }
590
591    /**
592     * Gets the summary from the accumulator.
593     *
594     * @return ActionResult with the summary or error
595     */
596    private ActionResult getAccumulatorSummary() {
597        if (accumulatorActor == null) {
598            return new ActionResult(false, "No accumulator created");
599        }
600        ActionResult result = accumulatorActor.callByActionName("getSummary", "");
601        // Print the summary to stdout
602        System.out.println(result.getResult());
603        return result;
604    }
605
606    /**
607     * Prints a summary of the current session's verification results.
608     *
609     * <p>Groups results by vertex name (step) and displays a formatted table.</p>
610     *
611     * @return ActionResult with success status and summary text
612     */
613    private ActionResult printSessionSummary() {
614        DistributedLogStore logStore = this.object.getLogStore();
615        long sessionId = this.object.getSessionId();
616
617        if (logStore == null || sessionId < 0) {
618            String msg = "Log store not available";
619            System.out.println(msg);
620            return new ActionResult(false, msg);
621        }
622
623        // Get all logs for this session
624        List<com.scivicslab.actoriac.log.LogEntry> logs = logStore.getLogsByLevel(sessionId,
625            com.scivicslab.actoriac.log.LogLevel.DEBUG);
626
627        // Group logs by vertex name and count results
628        java.util.Map<String, VerifyResult> resultsByVertex = new java.util.LinkedHashMap<>();
629
630        for (com.scivicslab.actoriac.log.LogEntry entry : logs) {
631            String message = entry.getMessage();
632            String vertexName = entry.getVertexName();
633            if (message == null) continue;
634
635            // Extract vertex name from the message if it contains step info
636            // Format: "- states: [...]\n  vertexName: xxx\n..."
637            if (vertexName != null && vertexName.contains("vertexName:")) {
638                int idx = vertexName.indexOf("vertexName:");
639                if (idx >= 0) {
640                    String rest = vertexName.substring(idx + 11).trim();
641                    int end = rest.indexOf('\n');
642                    vertexName = end > 0 ? rest.substring(0, end).trim() : rest.trim();
643                }
644            }
645
646            // Skip non-verify steps
647            if (vertexName == null || !vertexName.startsWith("verify-")) {
648                continue;
649            }
650
651            VerifyResult result = resultsByVertex.computeIfAbsent(vertexName, k -> new VerifyResult());
652
653            // Count occurrences in message
654            result.okCount += countOccurrences(message, "[OK]");
655            result.warnCount += countOccurrences(message, "[WARN]");
656            result.errorCount += countOccurrences(message, "[ERROR]");
657            result.infoCount += countOccurrences(message, "[INFO]");
658
659            // Extract special info (like document count, cluster health)
660            extractSpecialInfo(message, result);
661        }
662
663        // Build summary output
664        StringBuilder sb = new StringBuilder();
665        sb.append("\n");
666        sb.append("============================================================\n");
667        sb.append("                 VERIFICATION SUMMARY\n");
668        sb.append("============================================================\n");
669        sb.append("\n");
670        sb.append(String.format("| %-35s | %-20s |\n", "Item", "Status"));
671        sb.append("|-------------------------------------|----------------------|\n");
672
673        // Mapping from vertex names to display names and aggregation
674        String[][] mappings = {
675            {"verify-repos", "Document repositories"},
676            {"verify-utility-cli", "Utility-cli"},
677            {"verify-utility-sau3", "Utility-sau3"},
678            {"verify-builds", "Docusaurus builds"},
679            {"verify-public-html", "public_html deploy"},
680            {"verify-apache", "Apache2 + UserDir"},
681            {"verify-opensearch-install", "OpenSearch install"},
682            {"verify-opensearch-running", "OpenSearch status"},
683            {"verify-docusearch-build", "quarkus-docusearch build"},
684            {"verify-docusearch-running", "quarkus-docusearch server"},
685            {"verify-search-index", "Search index"},
686            {"verify-web-access", "Web access"},
687        };
688
689        int totalOk = 0, totalWarn = 0, totalError = 0;
690        List<String> errorDetails = new ArrayList<>();
691        List<String> warnDetails = new ArrayList<>();
692
693        for (String[] mapping : mappings) {
694            String vertexName = mapping[0];
695            String displayName = mapping[1];
696            VerifyResult result = resultsByVertex.get(vertexName);
697
698            if (result == null) {
699                sb.append(String.format("| %-35s | %-20s |\n", displayName, "-"));
700                continue;
701            }
702
703            totalOk += result.okCount;
704            totalWarn += result.warnCount;
705            totalError += result.errorCount;
706
707            String status = formatStatus(result);
708            sb.append(String.format("| %-35s | %-20s |\n", displayName, status));
709
710            // Collect error/warning details
711            if (result.errorCount > 0) {
712                errorDetails.add(displayName + ": " + result.errorCount + " error(s)");
713            }
714            if (result.warnCount > 0) {
715                warnDetails.add(displayName + ": " + result.warnCount + " warning(s)");
716            }
717        }
718
719        sb.append("|-------------------------------------|----------------------|\n");
720        sb.append(String.format("| %-35s | %d OK, %d WARN, %d ERR |\n",
721            "TOTAL", totalOk, totalWarn, totalError));
722        sb.append("============================================================\n");
723
724        // Show error details if any
725        if (!errorDetails.isEmpty()) {
726            sb.append("\n--- Errors ---\n");
727            for (String detail : errorDetails) {
728                sb.append("  * ").append(detail).append("\n");
729            }
730        }
731
732        // Show warning details if any
733        if (!warnDetails.isEmpty()) {
734            sb.append("\n--- Warnings ---\n");
735            for (String detail : warnDetails) {
736                sb.append("  * ").append(detail).append("\n");
737            }
738        }
739
740        sb.append("\n");
741        if (totalError == 0 && totalWarn == 0) {
742            sb.append("All checks passed!\n");
743        } else if (totalError > 0) {
744            sb.append("To fix issues, run:\n");
745            sb.append("  ./actor_iac.java --dir ./docu-search --workflow main-setup\n");
746        }
747
748        String summary = sb.toString();
749        System.out.println(summary);
750        return new ActionResult(true, summary);
751    }
752
753    /**
754     * Formats the status string for a verification result.
755     */
756    private String formatStatus(VerifyResult result) {
757        if (result.errorCount > 0) {
758            if (result.okCount > 0) {
759                return String.format("%d OK, %d ERROR", result.okCount, result.errorCount);
760            }
761            return "ERROR";
762        }
763        if (result.warnCount > 0) {
764            if (result.okCount > 0) {
765                return String.format("%d OK, %d WARN", result.okCount, result.warnCount);
766            }
767            return "WARN";
768        }
769        if (result.okCount > 0) {
770            String extra = result.extraInfo != null ? " " + result.extraInfo : "";
771            return result.okCount + " OK" + extra;
772        }
773        return "OK";
774    }
775
776    /**
777     * Extracts special information from log messages (like document count, cluster health).
778     */
779    private void extractSpecialInfo(String message, VerifyResult result) {
780        // Extract document count from search index
781        if (message.contains("documents")) {
782            java.util.regex.Matcher m = java.util.regex.Pattern.compile("(\\d+)\\s+documents").matcher(message);
783            if (m.find()) {
784                result.extraInfo = "(" + m.group(1) + " docs)";
785            }
786        }
787        // Extract cluster health
788        if (message.contains("Cluster health:")) {
789            java.util.regex.Matcher m = java.util.regex.Pattern.compile("Cluster health:\\s*(\\w+)").matcher(message);
790            if (m.find()) {
791                result.extraInfo = "(" + m.group(1) + ")";
792            }
793        }
794        // Extract web access count
795        if (message.contains("Accessible at")) {
796            // Count from "X / Y" pattern is handled by OK count
797        }
798    }
799
800    /**
801     * Counts occurrences of a substring in a string.
802     */
803    private int countOccurrences(String text, String sub) {
804        int count = 0;
805        int idx = 0;
806        while ((idx = text.indexOf(sub, idx)) != -1) {
807            count++;
808            idx += sub.length();
809        }
810        return count;
811    }
812
813    /**
814     * Helper class to hold verification results for a step.
815     */
816    private static class VerifyResult {
817        int okCount = 0;
818        int warnCount = 0;
819        int errorCount = 0;
820        int infoCount = 0;
821        String extraInfo = null;
822    }
823
824}