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.cli;
019
020import java.io.File;
021import java.nio.file.Path;
022import java.sql.SQLException;
023import java.time.LocalDateTime;
024import java.time.ZoneId;
025import java.time.format.DateTimeFormatter;
026import java.util.List;
027import java.util.concurrent.Callable;
028
029import com.scivicslab.actoriac.log.H2LogReader;
030import com.scivicslab.actoriac.log.H2LogReader.NodeInfo;
031import com.scivicslab.actoriac.log.LogEntry;
032import com.scivicslab.actoriac.log.LogLevel;
033import com.scivicslab.actoriac.log.SessionSummary;
034
035import picocli.CommandLine;
036import picocli.CommandLine.Command;
037import picocli.CommandLine.Option;
038
039/**
040 * CLI tool for querying workflow execution logs from H2 database.
041 *
042 * <p>Usage examples:</p>
043 * <pre>
044 * # Show summary of the latest session
045 * java -jar actor-IaC.jar logs --db logs --summary
046 *
047 * # Show logs for a specific node
048 * java -jar actor-IaC.jar logs --db logs --node node-001
049 *
050 * # Show only error logs
051 * java -jar actor-IaC.jar logs --db logs --level ERROR
052 *
053 * # List all sessions
054 * java -jar actor-IaC.jar logs --db logs --list
055 * </pre>
056 *
057 * @author devteam@scivicslab.com
058 */
059@Command(
060    name = "log-search",
061    mixinStandardHelpOptions = true,
062    versionProvider = VersionProvider.class,
063    description = "Search workflow execution logs from H2 database."
064)
065public class LogsCLI implements Callable<Integer> {
066
067    /**
068     * ISO 8601 format with timezone offset (e.g., 2026-01-05T10:30:00+09:00).
069     */
070    private static final DateTimeFormatter ISO_FORMATTER =
071            DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX");
072
073    /**
074     * System timezone for display.
075     */
076    private static final ZoneId SYSTEM_ZONE = ZoneId.systemDefault();
077
078    @Option(
079        names = {"--db"},
080        description = "H2 database path (without .mv.db extension)",
081        required = true
082    )
083    private File dbPath;
084
085    @Option(
086        names = {"--server"},
087        description = "H2 log server address (host:port). Use with log-server command."
088    )
089    private String server;
090
091    @Option(
092        names = {"-s", "--session"},
093        description = "Session ID to query (default: latest session)"
094    )
095    private Long sessionId;
096
097    @Option(
098        names = {"-n", "--node"},
099        description = "Filter logs by node ID"
100    )
101    private String nodeId;
102
103    @Option(
104        names = {"--level"},
105        description = "Minimum log level to show (DEBUG, INFO, WARN, ERROR)",
106        defaultValue = "DEBUG"
107    )
108    private LogLevel minLevel;
109
110    @Option(
111        names = {"--summary"},
112        description = "Show session summary only"
113    )
114    private boolean summaryOnly;
115
116    @Option(
117        names = {"--list"},
118        description = "List recent sessions"
119    )
120    private boolean listSessions;
121
122    @Option(
123        names = {"-w", "--workflow"},
124        description = "Filter sessions by workflow name"
125    )
126    private String workflowFilter;
127
128    @Option(
129        names = {"-o", "--overlay"},
130        description = "Filter sessions by overlay name"
131    )
132    private String overlayFilter;
133
134    @Option(
135        names = {"-i", "--inventory"},
136        description = "Filter sessions by inventory name"
137    )
138    private String inventoryFilter;
139
140    @Option(
141        names = {"--after"},
142        description = "Filter sessions started after this time (ISO format: YYYY-MM-DDTHH:mm:ss)"
143    )
144    private String startedAfter;
145
146    @Option(
147        names = {"--since"},
148        description = "Filter sessions started within the specified duration (e.g., 12h, 1d, 3d, 1w)"
149    )
150    private String since;
151
152    @Option(
153        names = {"--ended-since"},
154        description = "Filter sessions ended within the specified duration (e.g., 1h, 12h, 1d)"
155    )
156    private String endedSince;
157
158    @Option(
159        names = {"--limit"},
160        description = "Maximum number of lines to show (default: unlimited)"
161    )
162    private int limit = 0;
163
164    @Option(
165        names = {"--list-nodes"},
166        description = "List all nodes in the specified session"
167    )
168    private boolean listNodes;
169
170    /**
171     * Main entry point for the logs CLI.
172     *
173     * @param args command line arguments
174     */
175    public static void main(String[] args) {
176        int exitCode = new CommandLine(new LogsCLI()).execute(args);
177        System.exit(exitCode);
178    }
179
180    @Override
181    public Integer call() {
182        try (H2LogReader reader = createReader()) {
183            if (listSessions) {
184                return listRecentSessions(reader);
185            }
186
187            // Determine session ID
188            long targetSession = sessionId != null ? sessionId : reader.getLatestSessionId();
189            if (targetSession < 0) {
190                System.err.println("No sessions found in database.");
191                return 1;
192            }
193
194            if (listNodes) {
195                return listNodesInSession(reader, targetSession);
196            }
197
198            if (summaryOnly) {
199                return showSummary(reader, targetSession);
200            }
201
202            return showLogs(reader, targetSession);
203
204        } catch (SQLException e) {
205            System.err.println("Database error: " + e.getMessage());
206            e.printStackTrace();
207            return 1;
208        } catch (Exception e) {
209            System.err.println("Error: " + e.getMessage());
210            e.printStackTrace();
211            return 1;
212        }
213    }
214
215    /**
216     * Creates an H2LogReader, using TCP connection if --server is specified.
217     *
218     * @return H2LogReader instance
219     * @throws SQLException if connection fails
220     */
221    private H2LogReader createReader() throws SQLException {
222        if (server != null && !server.isBlank()) {
223            String[] parts = server.split(":");
224            String host = parts[0];
225            int port = parts.length > 1 ? Integer.parseInt(parts[1]) : 29090;
226            return new H2LogReader(host, port, dbPath.getAbsolutePath());
227        } else {
228            return new H2LogReader(dbPath.toPath());
229        }
230    }
231
232    private Integer listRecentSessions(H2LogReader reader) {
233        // Parse start time filter (--since takes precedence over --after)
234        LocalDateTime startedAfterTime = null;
235        if (since != null) {
236            startedAfterTime = parseSince(since);
237            if (startedAfterTime == null) {
238                System.err.println("Invalid --since format. Use: 12h, 1d, 3d, 1w (h=hours, d=days, w=weeks)");
239                return 1;
240            }
241        } else if (startedAfter != null) {
242            try {
243                startedAfterTime = LocalDateTime.parse(startedAfter);
244            } catch (Exception e) {
245                System.err.println("Invalid date format. Use ISO format: YYYY-MM-DDTHH:mm:ss");
246                return 1;
247            }
248        }
249
250        // Parse end time filter
251        LocalDateTime endedAfterTime = null;
252        if (endedSince != null) {
253            endedAfterTime = parseSince(endedSince);
254            if (endedAfterTime == null) {
255                System.err.println("Invalid --ended-since format. Use: 1h, 12h, 1d, 3d (h=hours, d=days, w=weeks)");
256                return 1;
257            }
258        }
259
260        // Apply filters if any are specified
261        List<SessionSummary> sessions;
262        if (workflowFilter != null || overlayFilter != null || inventoryFilter != null || startedAfterTime != null || endedAfterTime != null) {
263            sessions = reader.listSessionsFiltered(workflowFilter, overlayFilter, inventoryFilter, startedAfterTime, endedAfterTime, limit);
264        } else {
265            sessions = reader.listSessions(limit);
266        }
267
268        if (sessions.isEmpty()) {
269            System.out.println("No sessions found.");
270            return 0;
271        }
272
273        System.out.println("Sessions:");
274        System.out.println("=".repeat(80));
275        for (SessionSummary summary : sessions) {
276            System.out.printf("#%-4d %-30s %-10s%n",
277                    summary.getSessionId(),
278                    summary.getWorkflowName(),
279                    summary.getStatus());
280            if (summary.getOverlayName() != null) {
281                System.out.printf("      Overlay:   %s%n", summary.getOverlayName());
282            }
283            if (summary.getInventoryName() != null) {
284                System.out.printf("      Inventory: %s%n", summary.getInventoryName());
285            }
286            System.out.printf("      Started:   %s%n", formatTimestamp(summary.getStartedAt()));
287            if (summary.getEndedAt() != null) {
288                System.out.printf("      Ended:     %s%n", formatTimestamp(summary.getEndedAt()));
289            }
290            if (summary.getCwd() != null) {
291                System.out.printf("      CWD:       %s%n", summary.getCwd());
292            }
293            if (summary.getGitCommit() != null) {
294                String gitInfo = summary.getGitCommit();
295                if (summary.getGitBranch() != null) {
296                    gitInfo += " (" + summary.getGitBranch() + ")";
297                }
298                System.out.printf("      Git:       %s%n", gitInfo);
299            }
300            if (summary.getCommandLine() != null) {
301                System.out.printf("      Command:   %s%n", summary.getCommandLine());
302            }
303            if (summary.getActorIacVersion() != null) {
304                String versionInfo = summary.getActorIacVersion();
305                if (summary.getActorIacCommit() != null) {
306                    versionInfo += " (commit: " + summary.getActorIacCommit() + ")";
307                }
308                System.out.printf("      actor-IaC: %s%n", versionInfo);
309            }
310            System.out.println("-".repeat(80));
311        }
312        return 0;
313    }
314
315    private Integer listNodesInSession(H2LogReader reader, long targetSession) {
316        List<NodeInfo> nodes = reader.getNodesInSession(targetSession);
317        if (nodes.isEmpty()) {
318            System.out.println("No nodes found in session #" + targetSession);
319            return 0;
320        }
321
322        SessionSummary summary = reader.getSummary(targetSession);
323        System.out.println("Nodes in session #" + targetSession + " (" + summary.getWorkflowName() + "):");
324        System.out.println("=".repeat(70));
325        System.out.printf("%-30s %-10s %-10s%n", "NODE_ID", "STATUS", "LOG_LINES");
326        System.out.println("-".repeat(70));
327        for (NodeInfo node : nodes) {
328            System.out.printf("%-30s %-10s %-10d%n",
329                    node.nodeId(),
330                    node.status() != null ? node.status() : "-",
331                    node.logCount());
332        }
333        System.out.println("=".repeat(70));
334        System.out.println("Total: " + nodes.size() + " nodes");
335        return 0;
336    }
337
338    private Integer showSummary(H2LogReader reader, long targetSession) {
339        SessionSummary summary = reader.getSummary(targetSession);
340        if (summary == null) {
341            System.err.println("Session not found: " + targetSession);
342            return 1;
343        }
344        System.out.println(summary);
345        return 0;
346    }
347
348    private Integer showLogs(H2LogReader reader, long targetSession) {
349        List<LogEntry> logs;
350
351        if (nodeId != null) {
352            logs = reader.getLogsByNode(targetSession, nodeId);
353            System.out.println("Logs for node: " + nodeId);
354        } else {
355            logs = reader.getLogsByLevel(targetSession, minLevel);
356            System.out.println("Logs (level >= " + minLevel + "):");
357        }
358
359        System.out.println("=".repeat(80));
360
361        int count = 0;
362        for (LogEntry entry : logs) {
363            if (limit > 0 && count >= limit) {
364                System.out.println("... (truncated at " + limit + " lines, use --limit to change)");
365                break;
366            }
367
368            // Format: [timestamp] LEVEL [node] message
369            String levelColor = getLevelPrefix(entry.getLevel());
370            System.out.printf("%s[%s] %-5s [%s] %s%s%n",
371                    levelColor,
372                    formatTimestamp(entry.getTimestamp()),
373                    entry.getLevel(),
374                    entry.getNodeId(),
375                    entry.getMessage(),
376                    "\u001B[0m"); // Reset color
377
378            count++;
379        }
380
381        System.out.println("=".repeat(80));
382        System.out.println("Total: " + logs.size() + " lines");
383
384        return 0;
385    }
386
387    private String getLevelPrefix(LogLevel level) {
388        return switch (level) {
389            case ERROR -> "\u001B[31m"; // Red
390            case WARN -> "\u001B[33m";  // Yellow
391            case INFO -> "\u001B[32m";  // Green
392            case DEBUG -> "\u001B[36m"; // Cyan
393        };
394    }
395
396    /**
397     * Formats a LocalDateTime as ISO 8601 string with timezone offset.
398     *
399     * <p>Example output: 2026-01-05T10:30:00+09:00</p>
400     *
401     * @param timestamp the timestamp to format
402     * @return ISO 8601 formatted string, or "N/A" if null
403     */
404    private String formatTimestamp(LocalDateTime timestamp) {
405        if (timestamp == null) {
406            return "N/A";
407        }
408        return timestamp.atZone(SYSTEM_ZONE).format(ISO_FORMATTER);
409    }
410
411    /**
412     * Parses a relative time string into a LocalDateTime.
413     *
414     * <p>Supported formats:</p>
415     * <ul>
416     *   <li>{@code 12h} - 12 hours ago</li>
417     *   <li>{@code 1d} - 1 day ago</li>
418     *   <li>{@code 3d} - 3 days ago</li>
419     *   <li>{@code 1w} - 1 week ago</li>
420     * </ul>
421     *
422     * @param sinceStr the relative time string
423     * @return LocalDateTime representing the calculated time, or null if invalid format
424     */
425    private LocalDateTime parseSince(String sinceStr) {
426        if (sinceStr == null || sinceStr.isEmpty()) {
427            return null;
428        }
429
430        try {
431            String numPart = sinceStr.substring(0, sinceStr.length() - 1);
432            char unit = Character.toLowerCase(sinceStr.charAt(sinceStr.length() - 1));
433            long amount = Long.parseLong(numPart);
434
435            LocalDateTime now = LocalDateTime.now();
436            return switch (unit) {
437                case 'h' -> now.minusHours(amount);
438                case 'd' -> now.minusDays(amount);
439                case 'w' -> now.minusWeeks(amount);
440                case 'm' -> now.minusMinutes(amount);
441                default -> null;
442            };
443        } catch (NumberFormatException | StringIndexOutOfBoundsException e) {
444            return null;
445        }
446    }
447}