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.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@scivics-lab.com
058 */
059@Command(
060    name = "log-search",
061    mixinStandardHelpOptions = true,
062    version = "actor-IaC log-search 2.10.0",
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 entries to show",
161        defaultValue = "100"
162    )
163    private int limit;
164
165    @Option(
166        names = {"--list-nodes"},
167        description = "List all nodes in the specified session"
168    )
169    private boolean listNodes;
170
171    /**
172     * Main entry point for the logs CLI.
173     *
174     * @param args command line arguments
175     */
176    public static void main(String[] args) {
177        int exitCode = new CommandLine(new LogsCLI()).execute(args);
178        System.exit(exitCode);
179    }
180
181    @Override
182    public Integer call() {
183        try (H2LogReader reader = createReader()) {
184            if (listSessions) {
185                return listRecentSessions(reader);
186            }
187
188            // Determine session ID
189            long targetSession = sessionId != null ? sessionId : reader.getLatestSessionId();
190            if (targetSession < 0) {
191                System.err.println("No sessions found in database.");
192                return 1;
193            }
194
195            if (listNodes) {
196                return listNodesInSession(reader, targetSession);
197            }
198
199            if (summaryOnly) {
200                return showSummary(reader, targetSession);
201            }
202
203            return showLogs(reader, targetSession);
204
205        } catch (SQLException e) {
206            System.err.println("Database error: " + e.getMessage());
207            return 1;
208        } catch (Exception e) {
209            System.err.println("Error: " + e.getMessage());
210            return 1;
211        }
212    }
213
214    /**
215     * Creates an H2LogReader, using TCP connection if --server is specified.
216     *
217     * @return H2LogReader instance
218     * @throws SQLException if connection fails
219     */
220    private H2LogReader createReader() throws SQLException {
221        if (server != null && !server.isBlank()) {
222            String[] parts = server.split(":");
223            String host = parts[0];
224            int port = parts.length > 1 ? Integer.parseInt(parts[1]) : 29090;
225            return new H2LogReader(host, port, dbPath.getAbsolutePath());
226        } else {
227            return new H2LogReader(dbPath.toPath());
228        }
229    }
230
231    private Integer listRecentSessions(H2LogReader reader) {
232        // Parse start time filter (--since takes precedence over --after)
233        LocalDateTime startedAfterTime = null;
234        if (since != null) {
235            startedAfterTime = parseSince(since);
236            if (startedAfterTime == null) {
237                System.err.println("Invalid --since format. Use: 12h, 1d, 3d, 1w (h=hours, d=days, w=weeks)");
238                return 1;
239            }
240        } else if (startedAfter != null) {
241            try {
242                startedAfterTime = LocalDateTime.parse(startedAfter);
243            } catch (Exception e) {
244                System.err.println("Invalid date format. Use ISO format: YYYY-MM-DDTHH:mm:ss");
245                return 1;
246            }
247        }
248
249        // Parse end time filter
250        LocalDateTime endedAfterTime = null;
251        if (endedSince != null) {
252            endedAfterTime = parseSince(endedSince);
253            if (endedAfterTime == null) {
254                System.err.println("Invalid --ended-since format. Use: 1h, 12h, 1d, 3d (h=hours, d=days, w=weeks)");
255                return 1;
256            }
257        }
258
259        // Apply filters if any are specified
260        List<SessionSummary> sessions;
261        if (workflowFilter != null || overlayFilter != null || inventoryFilter != null || startedAfterTime != null || endedAfterTime != null) {
262            sessions = reader.listSessionsFiltered(workflowFilter, overlayFilter, inventoryFilter, startedAfterTime, endedAfterTime, limit);
263        } else {
264            sessions = reader.listSessions(limit);
265        }
266
267        if (sessions.isEmpty()) {
268            System.out.println("No sessions found.");
269            return 0;
270        }
271
272        System.out.println("Sessions:");
273        System.out.println("=".repeat(80));
274        for (SessionSummary summary : sessions) {
275            System.out.printf("#%-4d %-30s %-10s%n",
276                    summary.getSessionId(),
277                    summary.getWorkflowName(),
278                    summary.getStatus());
279            if (summary.getOverlayName() != null) {
280                System.out.printf("      Overlay:   %s%n", summary.getOverlayName());
281            }
282            if (summary.getInventoryName() != null) {
283                System.out.printf("      Inventory: %s%n", summary.getInventoryName());
284            }
285            System.out.printf("      Started:   %s%n", formatTimestamp(summary.getStartedAt()));
286            System.out.println("-".repeat(80));
287        }
288        return 0;
289    }
290
291    private Integer listNodesInSession(H2LogReader reader, long targetSession) {
292        List<NodeInfo> nodes = reader.getNodesInSession(targetSession);
293        if (nodes.isEmpty()) {
294            System.out.println("No nodes found in session #" + targetSession);
295            return 0;
296        }
297
298        SessionSummary summary = reader.getSummary(targetSession);
299        System.out.println("Nodes in session #" + targetSession + " (" + summary.getWorkflowName() + "):");
300        System.out.println("=".repeat(70));
301        System.out.printf("%-30s %-10s %-10s%n", "NODE_ID", "STATUS", "LOG_COUNT");
302        System.out.println("-".repeat(70));
303        for (NodeInfo node : nodes) {
304            System.out.printf("%-30s %-10s %-10d%n",
305                    node.nodeId(),
306                    node.status() != null ? node.status() : "-",
307                    node.logCount());
308        }
309        System.out.println("=".repeat(70));
310        System.out.println("Total: " + nodes.size() + " nodes");
311        return 0;
312    }
313
314    private Integer showSummary(H2LogReader reader, long targetSession) {
315        SessionSummary summary = reader.getSummary(targetSession);
316        if (summary == null) {
317            System.err.println("Session not found: " + targetSession);
318            return 1;
319        }
320        System.out.println(summary);
321        return 0;
322    }
323
324    private Integer showLogs(H2LogReader reader, long targetSession) {
325        List<LogEntry> logs;
326
327        if (nodeId != null) {
328            logs = reader.getLogsByNode(targetSession, nodeId);
329            System.out.println("Logs for node: " + nodeId);
330        } else {
331            logs = reader.getLogsByLevel(targetSession, minLevel);
332            System.out.println("Logs (level >= " + minLevel + "):");
333        }
334
335        System.out.println("=".repeat(80));
336
337        int count = 0;
338        for (LogEntry entry : logs) {
339            if (count >= limit) {
340                System.out.println("... (truncated, use --limit to show more)");
341                break;
342            }
343
344            // Format: [timestamp] LEVEL [node] message
345            String levelColor = getLevelPrefix(entry.getLevel());
346            System.out.printf("%s[%s] %-5s [%s] %s%s%n",
347                    levelColor,
348                    formatTimestamp(entry.getTimestamp()),
349                    entry.getLevel(),
350                    entry.getNodeId(),
351                    entry.getMessage(),
352                    "\u001B[0m"); // Reset color
353
354            count++;
355        }
356
357        System.out.println("=".repeat(80));
358        System.out.println("Total: " + logs.size() + " entries");
359
360        return 0;
361    }
362
363    private String getLevelPrefix(LogLevel level) {
364        return switch (level) {
365            case ERROR -> "\u001B[31m"; // Red
366            case WARN -> "\u001B[33m";  // Yellow
367            case INFO -> "\u001B[32m";  // Green
368            case DEBUG -> "\u001B[36m"; // Cyan
369        };
370    }
371
372    /**
373     * Formats a LocalDateTime as ISO 8601 string with timezone offset.
374     *
375     * <p>Example output: 2026-01-05T10:30:00+09:00</p>
376     *
377     * @param timestamp the timestamp to format
378     * @return ISO 8601 formatted string, or "N/A" if null
379     */
380    private String formatTimestamp(LocalDateTime timestamp) {
381        if (timestamp == null) {
382            return "N/A";
383        }
384        return timestamp.atZone(SYSTEM_ZONE).format(ISO_FORMATTER);
385    }
386
387    /**
388     * Parses a relative time string into a LocalDateTime.
389     *
390     * <p>Supported formats:</p>
391     * <ul>
392     *   <li>{@code 12h} - 12 hours ago</li>
393     *   <li>{@code 1d} - 1 day ago</li>
394     *   <li>{@code 3d} - 3 days ago</li>
395     *   <li>{@code 1w} - 1 week ago</li>
396     * </ul>
397     *
398     * @param sinceStr the relative time string
399     * @return LocalDateTime representing the calculated time, or null if invalid format
400     */
401    private LocalDateTime parseSince(String sinceStr) {
402        if (sinceStr == null || sinceStr.isEmpty()) {
403            return null;
404        }
405
406        try {
407            String numPart = sinceStr.substring(0, sinceStr.length() - 1);
408            char unit = Character.toLowerCase(sinceStr.charAt(sinceStr.length() - 1));
409            long amount = Long.parseLong(numPart);
410
411            LocalDateTime now = LocalDateTime.now();
412            return switch (unit) {
413                case 'h' -> now.minusHours(amount);
414                case 'd' -> now.minusDays(amount);
415                case 'w' -> now.minusWeeks(amount);
416                case 'm' -> now.minusMinutes(amount);
417                default -> null;
418            };
419        } catch (NumberFormatException | StringIndexOutOfBoundsException e) {
420            return null;
421        }
422    }
423}