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.io.BufferedReader;
021import java.io.IOException;
022import java.io.InputStreamReader;
023import java.nio.file.Files;
024import java.nio.file.Path;
025import java.util.HashSet;
026import java.util.Set;
027import java.util.logging.Level;
028import java.util.logging.Logger;
029
030import com.github.ricksbrown.cowsay.Cowsay;
031import com.scivicslab.pojoactor.core.ActionResult;
032import com.scivicslab.pojoactor.workflow.IIActorSystem;
033import com.scivicslab.pojoactor.workflow.Interpreter;
034import com.scivicslab.pojoactor.workflow.Vertex;
035
036/**
037 * Level 3 wrapper that adds workflow capabilities to a Node POJO.
038 *
039 * <p>This class extends {@link Interpreter} to provide workflow execution
040 * capabilities while delegating SSH operations to a wrapped {@link Node} instance.</p>
041 *
042 * <p>This demonstrates the three-level architecture of actor-IaC:</p>
043 * <ul>
044 * <li><strong>Level 1 (POJO):</strong> {@link Node} - pure POJO with SSH functionality</li>
045 * <li><strong>Level 2 (Actor):</strong> ActorRef&lt;Node&gt; - actor wrapper for concurrent execution</li>
046 * <li><strong>Level 3 (Workflow):</strong> NodeInterpreter - workflow capabilities + IIActorRef wrapper</li>
047 * </ul>
048 *
049 * <p><strong>Design principle:</strong> Node remains a pure POJO, independent of ActorSystem.
050 * NodeInterpreter wraps Node to add workflow capabilities without modifying the Node class.</p>
051 *
052 * @author devteam@scivics-lab.com
053 */
054public class NodeInterpreter extends Interpreter {
055
056    private static final Logger logger = Logger.getLogger(NodeInterpreter.class.getName());
057
058    /**
059     * The wrapped Node POJO that handles actual SSH operations.
060     */
061    private final Node node;
062
063    /**
064     * The overlay directory path for YAML overlay feature.
065     */
066    private String overlayDir;
067
068    /**
069     * The current vertex YAML snippet (first 10 lines) for accumulator reporting.
070     */
071    private String currentVertexYaml = "";
072
073    /**
074     * Set of changed document names detected by workflow.
075     * This replaces the /tmp/changed_docs.txt file-based approach.
076     */
077    private final Set<String> changedDocuments = new HashSet<>();
078
079    /**
080     * Constructs a NodeInterpreter that wraps the specified Node.
081     *
082     * @param node the {@link Node} instance to wrap
083     * @param system the actor system for workflow execution
084     */
085    public NodeInterpreter(Node node, IIActorSystem system) {
086        super();
087        this.node = node;
088        this.system = system;
089        // Initialize parent's logger (used by Interpreter.runWorkflow error handling)
090        super.logger = logger;
091    }
092
093    /**
094     * Executes a command on the remote node via SSH.
095     *
096     * <p>Delegates to the wrapped {@link Node#executeCommand(String)} method.</p>
097     *
098     * @param command the command to execute
099     * @return the result of the command execution
100     * @throws IOException if SSH connection fails
101     */
102    public Node.CommandResult executeCommand(String command) throws IOException {
103        return node.executeCommand(command);
104    }
105
106    /**
107     * Executes a command with sudo privileges on the remote node.
108     *
109     * <p>Delegates to the wrapped {@link Node#executeSudoCommand(String)} method.
110     * Requires SUDO_PASSWORD environment variable to be set.</p>
111     *
112     * @param command the command to execute with sudo
113     * @return the result of the command execution
114     * @throws IOException if SSH connection fails or SUDO_PASSWORD is not set
115     */
116    public Node.CommandResult executeSudoCommand(String command) throws IOException {
117        return node.executeSudoCommand(command);
118    }
119
120    /**
121     * Gets the hostname of the node.
122     *
123     * @return the hostname
124     */
125    public String getHostname() {
126        return node.getHostname();
127    }
128
129    /**
130     * Gets the username for SSH connections.
131     *
132     * @return the username
133     */
134    public String getUser() {
135        return node.getUser();
136    }
137
138    /**
139     * Gets the SSH port.
140     *
141     * @return the SSH port number
142     */
143    public int getPort() {
144        return node.getPort();
145    }
146
147    /**
148     * Gets the wrapped Node instance.
149     *
150     * <p>This allows direct access to the underlying POJO when needed.</p>
151     *
152     * @return the wrapped Node
153     */
154    public Node getNode() {
155        return node;
156    }
157
158    /**
159     * Sets the overlay directory for YAML overlay feature.
160     *
161     * @param overlayDir the path to the overlay directory containing overlay-conf.yaml
162     */
163    public void setOverlayDir(String overlayDir) {
164        this.overlayDir = overlayDir;
165    }
166
167    /**
168     * Gets the overlay directory path.
169     *
170     * @return the overlay directory path, or null if not set
171     */
172    public String getOverlayDir() {
173        return overlayDir;
174    }
175
176    /**
177     * Hook called when entering a vertex during workflow execution.
178     *
179     * <p>Displays the workflow name and first 10 lines of the vertex definition
180     * in YAML format using cowsay to provide visual separation between workflow steps.</p>
181     *
182     * @param vertex the vertex being entered
183     */
184    @Override
185    protected void onEnterVertex(Vertex vertex) {
186        // Get workflow name
187        String workflowName = (getCode() != null && getCode().getName() != null)
188                ? getCode().getName()
189                : "unknown-workflow";
190
191        // Get YAML-formatted output (first 10 lines)
192        String yamlText = vertex.toYamlString(10).trim();
193        this.currentVertexYaml = yamlText;
194
195        // Combine workflow name and vertex YAML
196        String displayText = "[" + workflowName + "]\n" + yamlText;
197        String[] cowsayArgs = { displayText };
198        System.out.println(Cowsay.say(cowsayArgs));
199    }
200
201    /**
202     * Returns the current vertex YAML snippet for accumulator reporting.
203     *
204     * @return the first 10 lines of the current vertex in YAML format
205     */
206    public String getCurrentVertexYaml() {
207        return currentVertexYaml;
208    }
209
210    /**
211     * Loads and runs a workflow file to completion with overlay support.
212     *
213     * <p>If overlayDir is set, the workflow is loaded with overlay applied.
214     * Variables defined in overlay-conf.yaml are substituted before execution.</p>
215     *
216     * @param workflowFile the workflow file path (YAML or JSON)
217     * @param maxIterations maximum number of state transitions allowed
218     * @return ActionResult with success=true if completed, false otherwise
219     */
220    @Override
221    public ActionResult runWorkflow(String workflowFile, int maxIterations) {
222        // If no overlay is set, use parent implementation
223        if (overlayDir == null) {
224            return super.runWorkflow(workflowFile, maxIterations);
225        }
226
227        try {
228            // Reset state for fresh execution
229            reset();
230
231            // Resolve workflow file path
232            Path workflowPath;
233            if (workflowBaseDir != null) {
234                workflowPath = Path.of(workflowBaseDir, workflowFile);
235            } else {
236                workflowPath = Path.of(workflowFile);
237            }
238
239            // Load workflow with overlay applied
240            Path overlayPath = Path.of(overlayDir);
241            readYaml(workflowPath, overlayPath);
242
243            // Run until end
244            return runUntilEnd(maxIterations);
245
246        } catch (Exception e) {
247            logger.log(Level.SEVERE, "Error running workflow with overlay: " + workflowFile, e);
248            return new ActionResult(false, "Error: " + e.getMessage());
249        }
250    }
251
252    // ========================================================================
253    // Document Change Detection API (replaces /tmp file-based approach)
254    // ========================================================================
255
256    /**
257     * Detects changed documents and stores them in POJO state.
258     *
259     * <p>This method replaces the shell script that wrote to /tmp/changed_docs.txt.
260     * It reads the document list, checks git status for each, and stores changed
261     * document names in the changedDocuments set.</p>
262     *
263     * @param docListPath path to the document list file
264     * @return ActionResult with detection summary
265     * @throws IOException if file operations fail
266     */
267    public ActionResult detectDocumentChanges(String docListPath) throws IOException {
268        // Clear previous results
269        changedDocuments.clear();
270
271        // Expand ~ to home directory
272        String expandedPath = docListPath.replace("~", System.getProperty("user.home"));
273        Path listPath = Path.of(expandedPath);
274
275        if (!Files.exists(listPath)) {
276            return new ActionResult(false, "Document list not found: " + docListPath);
277        }
278
279        // Check for FORCE_FULL_BUILD environment variable
280        boolean forceBuild = "true".equalsIgnoreCase(System.getenv("FORCE_FULL_BUILD"));
281
282        StringBuilder summary = new StringBuilder();
283
284        if (forceBuild) {
285            summary.append("=== FORCE_FULL_BUILD enabled: processing all documents ===\n");
286        } else {
287            summary.append("=== Detecting changes via git fetch ===\n");
288        }
289
290        try (BufferedReader reader = Files.newBufferedReader(listPath)) {
291            String line;
292            while ((line = reader.readLine()) != null) {
293                line = line.trim();
294
295                // Skip comments and empty lines
296                if (line.isEmpty() || line.startsWith("#")) {
297                    continue;
298                }
299
300                // Parse: path git_url
301                String[] parts = line.split("\\s+", 2);
302                String path = parts[0].replace("~", System.getProperty("user.home"));
303                String docName = Path.of(path).getFileName().toString();
304
305                if (forceBuild) {
306                    changedDocuments.add(docName);
307                    summary.append("  [FORCE] ").append(docName).append("\n");
308                    continue;
309                }
310
311                // Check document status
312                Path docPath = Path.of(path);
313                Path gitPath = docPath.resolve(".git");
314
315                if (!Files.exists(docPath)) {
316                    // New document
317                    changedDocuments.add(docName);
318                    summary.append("  [NEW] ").append(docName).append("\n");
319                } else if (!Files.exists(gitPath)) {
320                    // Not a git repository
321                    changedDocuments.add(docName);
322                    summary.append("  [NO-GIT] ").append(docName).append("\n");
323                } else {
324                    // Check for remote changes using git
325                    String status = checkGitStatus(docPath);
326                    if (status.startsWith("[CHANGED]") || status.startsWith("[UNKNOWN]")) {
327                        changedDocuments.add(docName);
328                    }
329                    summary.append("  ").append(status).append(" ").append(docName).append("\n");
330                }
331            }
332        }
333
334        summary.append("\n=== Change detection summary ===\n");
335        if (changedDocuments.isEmpty()) {
336            summary.append("No changes detected. All documents are up to date.\n");
337        } else {
338            summary.append("Documents to process: ").append(changedDocuments.size()).append("\n");
339            for (String doc : changedDocuments) {
340                summary.append(doc).append("\n");
341            }
342        }
343
344        // Print summary (like the original shell script did)
345        System.out.println(summary);
346
347        return new ActionResult(true, "Detected " + changedDocuments.size() + " changed documents");
348    }
349
350    /**
351     * Checks git status for a document directory.
352     *
353     * @param docPath path to the document directory
354     * @return status string like "[CHANGED]", "[UP-TO-DATE]", or "[UNKNOWN]"
355     */
356    private String checkGitStatus(Path docPath) {
357        try {
358            // git fetch
359            ProcessBuilder fetchPb = new ProcessBuilder("git", "fetch", "origin");
360            fetchPb.directory(docPath.toFile());
361            fetchPb.redirectErrorStream(true);
362            Process fetchProcess = fetchPb.start();
363            fetchProcess.waitFor();
364
365            // Get local HEAD
366            String local = runGitCommand(docPath, "git", "rev-parse", "HEAD");
367
368            // Get remote HEAD (try main first, then master)
369            String remote = runGitCommand(docPath, "git", "rev-parse", "origin/main");
370            if (remote == null || remote.equals("unknown")) {
371                remote = runGitCommand(docPath, "git", "rev-parse", "origin/master");
372            }
373
374            if (local == null || remote == null || local.equals("unknown") || remote.equals("unknown")) {
375                return "[UNKNOWN] (cannot determine state)";
376            }
377
378            if (!local.equals(remote)) {
379                String localShort = local.length() > 7 ? local.substring(0, 7) : local;
380                String remoteShort = remote.length() > 7 ? remote.substring(0, 7) : remote;
381                return "[CHANGED] (local: " + localShort + ", remote: " + remoteShort + ")";
382            }
383
384            return "[UP-TO-DATE]";
385
386        } catch (Exception e) {
387            logger.log(Level.WARNING, "Error checking git status for " + docPath, e);
388            return "[UNKNOWN] (error: " + e.getMessage() + ")";
389        }
390    }
391
392    /**
393     * Runs a git command and returns the output.
394     */
395    private String runGitCommand(Path workDir, String... command) {
396        try {
397            ProcessBuilder pb = new ProcessBuilder(command);
398            pb.directory(workDir.toFile());
399            pb.redirectErrorStream(true);
400            Process process = pb.start();
401
402            try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
403                String line = reader.readLine();
404                process.waitFor();
405                return line != null ? line.trim() : "unknown";
406            }
407        } catch (Exception e) {
408            return "unknown";
409        }
410    }
411
412    /**
413     * Checks if a specific document is in the changed list.
414     *
415     * @param docName the document name to check
416     * @return true if the document was detected as changed
417     */
418    public boolean isDocumentChanged(String docName) {
419        return changedDocuments.contains(docName);
420    }
421
422    /**
423     * Gets the number of changed documents.
424     *
425     * @return the count of changed documents
426     */
427    public int getChangedDocumentsCount() {
428        return changedDocuments.size();
429    }
430
431    /**
432     * Gets all changed document names.
433     *
434     * @return unmodifiable set of changed document names
435     */
436    public Set<String> getChangedDocuments() {
437        return Set.copyOf(changedDocuments);
438    }
439
440    /**
441     * Checks if there are any changed documents to process.
442     *
443     * @return true if at least one document needs processing
444     */
445    public boolean hasChangedDocuments() {
446        return !changedDocuments.isEmpty();
447    }
448
449    /**
450     * Clears the changed documents list.
451     */
452    public void clearChangedDocuments() {
453        changedDocuments.clear();
454    }
455
456    /**
457     * Adds a document to the changed list (for testing or manual override).
458     *
459     * @param docName the document name to add
460     */
461    public void addChangedDocument(String docName) {
462        changedDocuments.add(docName);
463    }
464
465    /**
466     * Clones changed documents from git.
467     *
468     * <p>Only clones documents that are in the changedDocuments set.
469     * Removes existing directory and does fresh clone to avoid conflicts.</p>
470     *
471     * @param docListPath path to the document list file
472     * @return ActionResult with clone summary
473     * @throws IOException if operations fail
474     */
475    public ActionResult cloneChangedDocuments(String docListPath) throws IOException {
476        if (changedDocuments.isEmpty()) {
477            System.out.println("=== No documents to clone (all up to date) ===");
478            return new ActionResult(true, "No documents to clone");
479        }
480
481        String expandedPath = docListPath.replace("~", System.getProperty("user.home"));
482        Path listPath = Path.of(expandedPath);
483
484        // Ensure ~/works exists
485        node.executeCommand("mkdir -p ~/works");
486
487        System.out.println("=== Cloning changed documents ===");
488        int clonedCount = 0;
489
490        try (BufferedReader reader = Files.newBufferedReader(listPath)) {
491            String line;
492            while ((line = reader.readLine()) != null) {
493                line = line.trim();
494                if (line.isEmpty() || line.startsWith("#")) continue;
495
496                String[] parts = line.split("\\s+", 2);
497                String path = parts[0];
498                String gitUrl = parts.length > 1 ? parts[1] : null;
499                String docName = Path.of(path).getFileName().toString();
500
501                if (!changedDocuments.contains(docName)) {
502                    System.out.println("  [SKIP] " + docName + " (unchanged)");
503                    continue;
504                }
505
506                if (gitUrl != null && !gitUrl.isEmpty()) {
507                    // Remove old and clone fresh
508                    System.out.println("=== Cloning: " + gitUrl + " -> " + path + " ===");
509                    node.executeCommand("rm -rf " + path);
510                    Node.CommandResult result = node.executeCommand("git clone " + gitUrl + " " + path);
511                    if (result.getExitCode() == 0) {
512                        clonedCount++;
513                    } else {
514                        System.err.println("Clone failed: " + result.getStderr());
515                    }
516                } else {
517                    System.out.println("=== No git URL specified for: " + path + " ===");
518                }
519            }
520        }
521
522        return new ActionResult(true, "Cloned " + clonedCount + " documents");
523    }
524
525    /**
526     * Builds changed Docusaurus documents.
527     *
528     * <p>Only builds documents that are in the changedDocuments set.</p>
529     *
530     * @param docListPath path to the document list file
531     * @return ActionResult with build summary
532     * @throws IOException if operations fail
533     */
534    public ActionResult buildChangedDocuments(String docListPath) throws IOException {
535        if (changedDocuments.isEmpty()) {
536            System.out.println("=== No documents to build (all up to date) ===");
537            return new ActionResult(true, "No documents to build");
538        }
539
540        String expandedPath = docListPath.replace("~", System.getProperty("user.home"));
541        Path listPath = Path.of(expandedPath);
542
543        System.out.println("=== Building changed documents ===");
544        int builtCount = 0;
545
546        try (BufferedReader reader = Files.newBufferedReader(listPath)) {
547            String line;
548            while ((line = reader.readLine()) != null) {
549                line = line.trim();
550                if (line.isEmpty() || line.startsWith("#")) continue;
551
552                String[] parts = line.split("\\s+", 2);
553                String path = parts[0];
554                String docName = Path.of(path).getFileName().toString();
555
556                if (!changedDocuments.contains(docName)) {
557                    System.out.println("  [SKIP] " + docName + " (unchanged)");
558                    continue;
559                }
560
561                System.out.println("=== Building: " + path + " ===");
562                Node.CommandResult result = node.executeCommand(
563                    "cd " + path + " && yarn install && yarn build"
564                );
565                if (result.getExitCode() == 0) {
566                    builtCount++;
567                } else {
568                    System.err.println("Build failed for " + docName + ": " + result.getStderr());
569                }
570            }
571        }
572
573        return new ActionResult(true, "Built " + builtCount + " documents");
574    }
575
576    /**
577     * Copies changed document builds to public_html.
578     *
579     * <p>Only copies documents that are in the changedDocuments set.</p>
580     *
581     * @param docListPath path to the document list file
582     * @return ActionResult with copy summary
583     * @throws IOException if operations fail
584     */
585    public ActionResult deployChangedDocuments(String docListPath) throws IOException {
586        // Ensure public_html exists
587        node.executeCommand("mkdir -p ~/public_html");
588
589        if (changedDocuments.isEmpty()) {
590            System.out.println("=== No documents to copy (all up to date) ===");
591            node.executeCommand("ls -la ~/public_html/");
592            return new ActionResult(true, "No documents to copy");
593        }
594
595        String expandedPath = docListPath.replace("~", System.getProperty("user.home"));
596        Path listPath = Path.of(expandedPath);
597
598        System.out.println("=== Copying changed documents to public_html ===");
599        int copiedCount = 0;
600
601        try (BufferedReader reader = Files.newBufferedReader(listPath)) {
602            String line;
603            while ((line = reader.readLine()) != null) {
604                line = line.trim();
605                if (line.isEmpty() || line.startsWith("#")) continue;
606
607                String[] parts = line.split("\\s+", 2);
608                String path = parts[0];
609                String docName = Path.of(path).getFileName().toString();
610
611                if (!changedDocuments.contains(docName)) {
612                    System.out.println("  [SKIP] " + docName + " (unchanged)");
613                    continue;
614                }
615
616                String buildPath = path + "/build";
617                String destPath = "~/public_html/" + docName;
618
619                System.out.println("=== Copying " + docName + " to public_html ===");
620                node.executeCommand("rm -rf " + destPath);
621                Node.CommandResult result = node.executeCommand(
622                    "cp -r " + buildPath + " " + destPath
623                );
624                if (result.getExitCode() == 0) {
625                    copiedCount++;
626                } else {
627                    System.err.println("Copy failed for " + docName + ": " + result.getStderr());
628                }
629            }
630        }
631
632        System.out.println("=== public_html contents ===");
633        node.executeCommand("ls -la ~/public_html/");
634
635        return new ActionResult(true, "Deployed " + copiedCount + " documents");
636    }
637}