// SPDX-License-Identifier: Apache-2.0
import AdmZip from 'adm-zip';
import fs from 'node:fs';
import path from 'node:path';
import chalk from 'chalk';
import * as constants from '../../core/constants.js';
import { PathEx } from '../../business/utils/path-ex.js';
const { green, yellow } = chalk;
/**
 * DiagnosticsAnalyzer scans a previously-collected diagnostics output directory
 * (produced by `deployment diagnostics logs`) and identifies common failure
 * signatures without requiring a live cluster connection.
 *
 * ## Input sources
 *
 * ### 1. Pod describe files  (`*.describe.txt`)
 * Written by `downloadHieroComponentLogs()` for every pod across all clusters.
 * These are the output of `kubectl describe pod <name> -n <namespace>` and
 * contain the pod's status, container states, events, and resource usage.
 *
 * Detectable errors:
 *
 * | Category        | Detected keywords / conditions                                                         |
 * |-----------------|----------------------------------------------------------------------------------------|
 * | `image-pull`    | `ErrImagePull`, `ImagePullBackOff`, `Back-off pulling image`,                          |
 * |                 | `failed to pull and unpack image`, `unexpected EOF` (truncated layer),                 |
 * |                 | `toomanyrequests`, `rate limit exceeded`, `429 Too Many Requests`                      |
 * | `oom`           | `OOMKilled`, `out of memory`, `reason: OOMKilled`                                      |
 * | `pod-readiness` | Pod `Status` field is not `Running`, or `Ready: False` is present in container status; |
 * |                 | supporting `Reason:` / `Message:` lines are captured as evidence                       |
 *
 * ### 2. Consensus node log archives  (`*-log-config.zip`)
 * Written by `getNodeLogsAndConfigs()` under `~/.solo/logs/<namespace>/`.
 * Each zip contains the node's log and config snapshot.  Only two log files
 * inside the archive are inspected:
 *
 * - `output/swirlds.log` — Hashgraph platform log
 * - `output/hgcaa.log`   — Hedera application log
 *
 * Detectable errors:
 *
 * | Category           | Detected keywords / conditions                                                      |
 * |--------------------|-------------------------------------------------------------------------------------|
 * | `consensus-active` | `swirlds.log` never contains the word `ACTIVE` — the node stalled during           |
 * |                    | startup (e.g. stuck in `STARTING_UP`, `OBSERVING`, or `REPLAYING_EVENTS`);         |
 * |                    | status-transition lines are captured as evidence                                    |
 * | `log-exception`    | Any line in `swirlds.log` or `hgcaa.log` matching `Exception`, `Error`,            |
 * |                    | or `Caused by:` — the first matching stack-trace block (up to 14 lines) is         |
 * |                    | captured as evidence                                                                |
 *
 * ## Output
 * All findings are written to `diagnostics-analysis.txt` inside the input
 * directory.  Up to 10 findings are also printed to the terminal in severity
 * order.  Duplicate findings (same category + title + source) are suppressed.
 */
export class DiagnosticsAnalyzer {
    logger;
    static CONSENSUS_LOG_DEFINITIONS = [
        { entrySuffix: 'output/swirlds.log', displayName: 'swirlds.log', checkConsensusActive: true },
        { entrySuffix: 'output/hgcaa.log', displayName: 'hgcaa.log', checkConsensusActive: false },
    ];
    constructor(logger) {
        this.logger = logger;
    }
    /**
     * Run the full analysis against `customOutputDirectory` (or the default
     * `~/.solo/logs/hiero-components-logs` when empty).
     *
     * Consensus node zip archives are looked up under
     * `~/.solo/logs/<namespaceName>/` when `namespaceName` is provided, or
     * directly under `~/.solo/logs/` otherwise.
     */
    analyze(customOutputDirectory, namespaceName) {
        const hieroOutputDirectory = customOutputDirectory
            ? path.resolve(customOutputDirectory)
            : PathEx.join(constants.SOLO_LOGS_DIR, 'hiero-components-logs');
        const findings = [];
        this.logger.showUser(`Scanning directory: ${hieroOutputDirectory}`);
        if (fs.existsSync(hieroOutputDirectory)) {
            this.analyzeDescribeFiles(hieroOutputDirectory, findings);
        }
        else {
            this.logger.showUser(yellow(`  Pod describe directory not found, skipping: ${hieroOutputDirectory}`));
        }
        let consensusArchiveDirectory = constants.SOLO_LOGS_DIR;
        if (customOutputDirectory) {
            consensusArchiveDirectory = path.resolve(customOutputDirectory);
        }
        else if (namespaceName) {
            consensusArchiveDirectory = PathEx.join(constants.SOLO_LOGS_DIR, namespaceName);
        }
        if (fs.existsSync(consensusArchiveDirectory)) {
            this.analyzeConsensusNodeArchives(consensusArchiveDirectory, findings);
        }
        else {
            this.logger.showUser(yellow(`  Consensus archive directory not found, skipping: ${consensusArchiveDirectory}`));
        }
        if (!fs.existsSync(hieroOutputDirectory)) {
            fs.mkdirSync(hieroOutputDirectory, { recursive: true });
        }
        const reportPath = PathEx.join(hieroOutputDirectory, 'diagnostics-analysis.txt');
        this.logger.showUser(`Writing report to: ${reportPath}`);
        const reportText = this.renderDiagnosticsFindings(findings);
        fs.writeFileSync(reportPath, reportText, 'utf8');
        if (findings.length > 0) {
            this.logger.showUser(yellow(`Detected ${findings.length} potential issue(s) from diagnostics logs. Summary written to ${reportPath}`));
            for (const [index, finding] of findings.slice(0, 10).entries()) {
                this.logger.showUser(`${index + 1}. ${finding.title} [${finding.source}]`);
                if (finding.evidence.length > 0) {
                    const maxEvidenceLines = finding.category === 'log-exception' ? 8 : 4;
                    for (const evidenceLine of finding.evidence.slice(0, maxEvidenceLines)) {
                        this.logger.showUser(`   - ${evidenceLine}`);
                    }
                    if (finding.evidence.length > maxEvidenceLines) {
                        this.logger.showUser(`   ... and ${finding.evidence.length - maxEvidenceLines} more evidence line(s) in diagnostics-analysis.txt`);
                    }
                }
            }
            if (findings.length > 10) {
                this.logger.showUser(`... and ${findings.length - 10} more. See diagnostics-analysis.txt for details.`);
            }
        }
        else {
            this.logger.showUser(green(`No common failure signatures detected. Report: ${reportPath}`));
        }
    }
    /**
     * Recursively scans `rootDirectory` for `*.describe.txt` files (one per pod)
     * and checks each for image-pull failures, OOM kills, and pod-readiness
     * problems.
     *
     * Detected errors:
     *  - `image-pull`    ErrImagePull / ImagePullBackOff / rate-limit / unexpected EOF
     *  - `oom`           OOMKilled / out of memory
     *  - `pod-readiness` Status != Running  OR  Ready: False
     */
    analyzeDescribeFiles(rootDirectory, findings) {
        const describeFiles = this.collectFilesRecursively(rootDirectory, (filePath) => filePath.endsWith('.describe.txt'));
        // Matches any image-pull error surfaced in `kubectl describe pod` output.
        // Covers:
        //   - ErrImagePull / ImagePullBackOff  (standard Kubernetes pull errors)
        //   - "Back-off pulling image"          (CRI back-off message in Events)
        //   - "failed to pull and unpack image" (containerd error)
        //   - "unexpected EOF"                  (truncated layer download)
        //   - toomanyrequests / rate limit exceeded / 429 Too Many Requests
        //     (Docker Hub and other registries throttle anonymous pulls)
        const imagePullPattern = /ErrImagePull|ImagePullBackOff|Back-off pulling image|failed to pull and unpack image|unexpected EOF|toomanyrequests|rate limit exceeded|429 Too Many Requests/i;
        // Matches out-of-memory kills.
        // "OOMKilled" appears in the container's LastTerminationState and in Events.
        // "reason: OOMKilled" is the structured field in the container status JSON.
        const oomPattern = /OOMKilled|out of memory|reason:\s*OOMKilled/i;
        this.logger.showUser(`  Found ${describeFiles.length} pod describe file(s)`);
        for (const describeFile of describeFiles) {
            const relatedPath = path.relative(rootDirectory, describeFile);
            this.logger.showUser(`  Reading: ${relatedPath}`);
            let content;
            try {
                content = fs.readFileSync(describeFile, 'utf8');
            }
            catch (error) {
                this.logger.showUser(yellow(`  Unable to read describe file ${relatedPath}: ${error.message}`));
                continue;
            }
            const podName = path.basename(describeFile, '.describe.txt');
            const source = path.relative(rootDirectory, describeFile);
            if (imagePullPattern.test(content)) {
                this.addDiagnosticsFinding(findings, {
                    category: 'image-pull',
                    title: `Image pull failure detected for pod ${podName}`,
                    source,
                    evidence: this.extractMatchSnippets(content, imagePullPattern, 8),
                });
            }
            if (oomPattern.test(content)) {
                this.addDiagnosticsFinding(findings, {
                    category: 'oom',
                    title: `OOM-related failure detected for pod ${podName}`,
                    source,
                    evidence: this.extractMatchSnippets(content, oomPattern, 6),
                });
            }
            // A pod is unhealthy if its top-level status is anything other than
            // "Running" or if any container is not ready.
            //
            // Two file formats are possible depending on how the describe file was
            // collected:
            //   - Text format (kubectl describe pod):  "Status: Pending"
            //                                          "Ready: False"
            //   - YAML format (kubectl get pod -o yaml): "phase: Pending"
            //                                            "ready: false"
            //
            // Both are matched so the check is format-agnostic.
            // Reason: / Message: / reason: / message: lines (case-insensitive) are
            // captured for additional context.
            const statusMatch = content.match(/^\s*(?:Status|phase):\s+([^\n]+)/m);
            const status = statusMatch?.[1]?.trim().replaceAll(/^"|"$/g, '') ?? '';
            const readyFalse = /^\s*[Rr]eady:\s+[Ff]alse\b/m.test(content);
            if ((status && status !== constants.POD_PHASE_RUNNING) || readyFalse) {
                const evidence = [];
                if (status) {
                    evidence.push(`Status: ${status}`);
                }
                if (readyFalse) {
                    evidence.push('Ready: False');
                }
                evidence.push(...this.extractMatchSnippets(content, /^\s*(Reason|Message):\s+.+$/i, 8));
                this.addDiagnosticsFinding(findings, {
                    category: 'pod-readiness',
                    title: `Pod not ready/running: ${podName}`,
                    source,
                    evidence,
                });
            }
        }
    }
    /**
     * Recursively scans `archiveRootDirectory` for `*-log-config.zip` archives
     * produced by `getNodeLogsAndConfigs()` and inspects two log files inside
     * each archive:
     *
     *  - `output/swirlds.log` — checked for absence of the `ACTIVE` platform
     *    status marker (category: `consensus-active`) and for exception blocks
     *    (category: `log-exception`).
     *  - `output/hgcaa.log`   — checked for exception blocks only
     *    (category: `log-exception`).
     *
     * Only the first exception block per log file is captured (up to 14 lines)
     * to keep the report readable.
     */
    analyzeConsensusNodeArchives(archiveRootDirectory, findings) {
        const archiveFiles = this.collectFilesRecursively(archiveRootDirectory, (filePath) => filePath.endsWith('-log-config.zip'));
        this.logger.showUser(`  Found ${archiveFiles.length} consensus log archive(s)`);
        for (const archiveFile of archiveFiles) {
            const archiveName = path.basename(archiveFile);
            this.logger.showUser(`  Unzipping: ${archiveName}`);
            let archive;
            try {
                archive = new AdmZip(archiveFile, { readEntries: true });
            }
            catch (error) {
                this.logger.showUser(yellow(`  Unable to read archive ${archiveName}: ${error.message}`));
                continue;
            }
            for (const entry of archive.getEntries()) {
                const logDefinition = this.findConsensusLogDefinition(entry.entryName);
                if (!logDefinition) {
                    continue;
                }
                this.analyzeConsensusLogEntry(archiveName, entry, logDefinition, findings);
            }
        }
    }
    findConsensusLogDefinition(entryName) {
        return DiagnosticsAnalyzer.CONSENSUS_LOG_DEFINITIONS.find((logDefinition) => entryName.endsWith(logDefinition.entrySuffix));
    }
    analyzeConsensusLogEntry(archiveName, entry, logDefinition, findings) {
        this.logger.showUser(`    Reading entry: ${entry.entryName}`);
        const source = `${archiveName}:${entry.entryName}`;
        const content = entry.getData().toString('utf8');
        if (logDefinition.checkConsensusActive) {
            this.analyzeConsensusActiveStatus(content, source, findings);
        }
        this.analyzeExceptionBlocks(logDefinition.displayName, content, source, findings);
    }
    /**
     * A healthy consensus node transitions through STARTING_UP → OBSERVING →
     * REPLAYING_EVENTS → ACTIVE. If `ACTIVE` never appears in swirlds.log,
     * the node likely stalled before becoming ready for transactions.
     */
    analyzeConsensusActiveStatus(content, source, findings) {
        if (/\bACTIVE\b/.test(content)) {
            return;
        }
        const evidence = this.extractMatchSnippets(content, /PlatformStatus|status|STARTING_UP|OBSERVING|REPLAYING_EVENTS|FREEZING|ACTIVE/i, 8);
        if (evidence.length === 0) {
            evidence.push('No ACTIVE status marker found in swirlds.log');
        }
        this.addDiagnosticsFinding(findings, {
            category: 'consensus-active',
            title: 'Consensus node may not have reached ACTIVE status',
            source,
            evidence,
        });
    }
    /**
     * Captures the first exception/stack-trace block from a consensus log file.
     */
    analyzeExceptionBlocks(logDisplayName, content, source, findings) {
        const exceptionBlocks = this.extractExceptionBlocks(content, 1, 14);
        if (exceptionBlocks.length === 0) {
            return;
        }
        this.addDiagnosticsFinding(findings, {
            category: 'log-exception',
            title: `Exception detected in ${logDisplayName}`,
            source,
            evidence: exceptionBlocks[0].split('\n').filter((line) => line.trim().length > 0),
        });
    }
    /**
     * Adds `finding` to `findings` unless an identical entry (same category,
     * title, and source) already exists.  Evidence lines are deduplicated and
     * capped at 14 entries to keep the report compact.
     */
    addDiagnosticsFinding(findings, finding) {
        const key = `${finding.category}|${finding.title}|${finding.source}`;
        const existingKeys = new Set(findings.map((item) => `${item.category}|${item.title}|${item.source}`));
        if (existingKeys.has(key)) {
            return;
        }
        findings.push({
            ...finding,
            evidence: [...new Set(finding.evidence)].filter((line) => line.trim().length > 0).slice(0, 14),
        });
    }
    /**
     * Walks `rootDirectory` recursively and returns all file paths for which
     * `matcher` returns `true`.
     */
    collectFilesRecursively(rootDirectory, matcher) {
        const files = [];
        const visit = (directory) => {
            const entries = fs.readdirSync(directory, { withFileTypes: true });
            for (const entry of entries) {
                const entryPath = path.join(directory, entry.name);
                if (entry.isDirectory()) {
                    visit(entryPath);
                    continue;
                }
                if (entry.isFile() && matcher(entryPath)) {
                    files.push(entryPath);
                }
            }
        };
        visit(rootDirectory);
        return files;
    }
    /**
     * Returns up to `maxMatches` lines from `content` that match `pattern`,
     * formatted as `"line <N>: <trimmed line>"`.
     *
     * The global (`g`) flag is stripped before matching so the RegExp lastIndex
     * does not interfere with repeated calls against the same pattern instance.
     */
    extractMatchSnippets(content, pattern, maxMatches) {
        const snippets = [];
        const lines = content.split(/\r?\n/);
        const normalizedFlags = pattern.flags.includes('g') ? pattern.flags.replaceAll('g', '') : pattern.flags;
        const matcher = new RegExp(pattern.source, normalizedFlags);
        for (const [index, line] of lines.entries()) {
            if (matcher.test(line)) {
                snippets.push(`line ${index + 1}: ${line.trim()}`);
                if (snippets.length >= maxMatches) {
                    break;
                }
            }
        }
        return snippets;
    }
    /**
     * Extracts up to `maxBlocks` exception/stack-trace blocks from `content`.
     *
     * A block starts on any line matching `Exception`, `Error`, or `Caused by:`
     * and continues as long as subsequent lines are stack frames (`at …`),
     * chained causes (`Caused by:`), or truncation markers (`… N more`).
     * Each block is capped at `maxLinesPerBlock` lines.
     */
    extractExceptionBlocks(content, maxBlocks, maxLinesPerBlock) {
        const lines = content.split(/\r?\n/);
        const blocks = [];
        const timestampPattern = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/;
        const levelContextPattern = /\b(?:ERROR|WARN|FATAL|SEVERE|EXCEPTION)\b/i;
        const exceptionTypeLinePattern = /^\s*(?:[a-z_][A-Za-z0-9_$]*\.)*[A-Z][A-Za-z0-9_$]*(?:Exception|Error|Throwable)(?::|\b)/;
        const startPattern = new RegExp(String.raw `${exceptionTypeLinePattern.source}|\b(?:Exception|Error)\b|^\s*Caused by:`);
        for (let index = 0; index < lines.length && blocks.length < maxBlocks; index++) {
            if (!startPattern.test(lines[index])) {
                continue;
            }
            const blockLines = [lines[index]];
            // In swirlds/hgcaa logs, the actual throwable class line can follow a
            // timestamped ERROR/WARN marker line. If parsing starts from the
            // throwable line, include that marker line as context for the report.
            if (index > 0 &&
                blockLines.length < maxLinesPerBlock &&
                (/\bERROR\s+EXCEPTION\b/i.test(lines[index - 1]) ||
                    (timestampPattern.test(lines[index - 1]) && levelContextPattern.test(lines[index - 1]))) &&
                !blockLines.includes(lines[index - 1])) {
                blockLines.unshift(lines[index - 1]);
            }
            let next = index + 1;
            while (next < lines.length && blockLines.length < maxLinesPerBlock) {
                const line = lines[next];
                if (line.trim().length === 0 || timestampPattern.test(line)) {
                    break;
                }
                if (/^\s+at\s+/.test(line) ||
                    /^\s*Caused by:/.test(line) ||
                    /^\s*Suppressed:/.test(line) ||
                    /^\s*\.\.\.\s+\d+\s+more/.test(line) ||
                    exceptionTypeLinePattern.test(line)) {
                    blockLines.push(line);
                    next++;
                    continue;
                }
                break;
            }
            blocks.push(blockLines.join('\n'));
            index = next - 1;
        }
        return blocks;
    }
    /**
     * Renders all findings into a human-readable plain-text report, sorted by
     * severity (image-pull → oom → pod-readiness → consensus-active →
     * log-exception).  Returns the report as a string ready to be written to
     * `diagnostics-analysis.txt`.
     */
    renderDiagnosticsFindings(findings) {
        const severityOrder = {
            'image-pull': 1,
            oom: 2,
            'pod-readiness': 3,
            'consensus-active': 4,
            'log-exception': 5,
        };
        const categoryLabel = {
            'image-pull': 'Image Pull',
            oom: 'Out Of Memory',
            'pod-readiness': 'Pod Readiness',
            'consensus-active': 'Consensus Active State',
            'log-exception': 'Exception Stack',
        };
        const lines = ['Solo Diagnostics Analysis Report', `Generated: ${new Date().toISOString()}`, ''];
        if (findings.length === 0) {
            lines.push('No common failure signatures were detected.');
            return lines.join('\n');
        }
        const orderedFindings = [];
        for (const finding of findings) {
            let insertionIndex = orderedFindings.length;
            for (const [index, existingFinding] of orderedFindings.entries()) {
                if (severityOrder[finding.category] < severityOrder[existingFinding.category]) {
                    insertionIndex = index;
                    break;
                }
            }
            orderedFindings.splice(insertionIndex, 0, finding);
        }
        lines.push(`Detected ${orderedFindings.length} potential issue(s):`, '');
        for (const [index, finding] of orderedFindings.entries()) {
            lines.push(`${index + 1}. [${categoryLabel[finding.category]}] ${finding.title}`, `   Source: ${finding.source}`);
            if (finding.evidence.length > 0) {
                lines.push('   Evidence:');
                for (const evidenceLine of finding.evidence) {
                    lines.push(`   - ${evidenceLine}`);
                }
            }
            lines.push('');
        }
        return lines.join('\n');
    }
}
//# sourceMappingURL=diagnostics-analyzer.js.map