CliArgs.java

package org.egothor.methodatlas;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

import org.egothor.methodatlas.ai.AiOptions;
import org.egothor.methodatlas.emit.OutputMode;
import org.egothor.methodatlas.ai.AiProvider;
import org.egothor.methodatlas.ai.PromptTemplateException;
import org.egothor.methodatlas.ai.PromptTemplateKind;
import org.egothor.methodatlas.ai.PromptTemplateSet;
import org.egothor.methodatlas.ai.PromptTemplateValidator;

/**
 * Parses command-line arguments into a {@link CliConfig}.
 *
 * <p>
 * This class centralises all argument-parsing logic for the MethodAtlas
 * application. It is intentionally separated from {@link MethodAtlasApp} to
 * keep each class focused and below the project's cyclomatic-complexity
 * threshold.
 * </p>
 *
 * <p>
 * When a {@code -config <file>} argument is present it is processed first
 * (via a pre-scan) so that the YAML file provides default values before
 * individual command-line flags are evaluated. Command-line flags always take
 * precedence over values from the YAML configuration file.
 * </p>
 *
 * <p>
 * This class is a non-instantiable utility holder.
 * </p>
 *
 * @see CliConfig
 * @see MethodAtlasApp
 */
@SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.LongVariable"})
final class CliArgs {

    private static final String DEFAULT_FILE_SUFFIX = "java:Test.java";
    private static final String FLAG_CONFIG = "-config";
    private static final String FLAG_AI_CACHE = "-ai-cache";
    private static final String FLAG_AI_CACHE_OUT = "-ai-cache-out";
    private static final String FLAG_DRIFT_DETECT = "-drift-detect";
    private static final String FLAG_EMIT_SOURCE_ROOT = "-emit-source-root";
    private static final String FLAG_INCLUDE_NON_SECURITY = "-include-non-security";
    private static final String FLAG_SARIF_OMIT_SCORES = "-sarif-omit-scores";
    private static final String FLAG_APPLY_TAGS_FROM_CSV = "-apply-tags-from-csv";
    private static final String FLAG_PROMOTE_AI = "-promote-ai";
    private static final String FLAG_MISMATCH_LIMIT = "-mismatch-limit";
    private static final String FLAG_MIN_CONFIDENCE = "-min-confidence";
    private static final String FLAG_EMIT_RECEIPT = "-emit-receipt";
    private static final String FLAG_RECEIPT_FILE = "-receipt-file";
    private static final String FLAG_EMIT_COVERAGE = "-emit-coverage";
    private static final String FLAG_COVERAGE_FILE = "-coverage-file";
    private static final String FLAG_COVERAGE_MAPPING = "-coverage-mapping";
    private static final String FLAG_EVIDENCE_PACK = "-evidence-pack";
    private static final String FLAG_EVIDENCE_PACK_DIR = "-evidence-pack-dir";
    private static final String FLAG_EVIDENCE_PACK_OVERWRITE = "-evidence-pack-overwrite";
    private static final String FLAG_EVIDENCE_PACK_KEYRING = "-evidence-pack-keyring";
    private static final String FLAG_EVIDENCE_PACK_KEYRING_ENV = "-evidence-pack-keyring-env";
    private static final String FLAG_EVIDENCE_PACK_KEY_ALIAS = "-evidence-pack-key-alias";
    private static final String FLAG_EVIDENCE_PACK_SIGN_ALGO = "-evidence-pack-sign-algo";
    private static final String FLAG_DETECT_SECRETS = "-detect-secrets";
    private static final String FLAG_SECRETS_INCLUDE = "-secrets-include";
    private static final String FLAG_SECRETS_RULES = "-secrets-rules";
    private static final String FLAG_SECRETS_OUT = "-secrets-out";
    private static final String FLAG_SECRETS_SEPARATE_LLM = "-secrets-separate-llm";
    private static final String FLAG_SECRETS_SHOW_VALUES = "-secrets-show-values";
    private static final String FLAG_SECRETS_ERROR_THRESHOLD = "-secrets-error-threshold";
    private static final String FLAG_SECRETS_WARNING_THRESHOLD = "-secrets-warning-threshold";
    private static final String FLAG_SECRETS_MIN_SCORE = "-secrets-min-score";
    private static final String FLAG_CLASSIFICATION_PROMPT = "-classification-prompt";
    private static final String FLAG_TRIAGE_PROMPT = "-triage-prompt";
    private static final String FLAG_DEDICATED_TRIAGE_PROMPT = "-dedicated-triage-prompt";

    /**
     * Prevents instantiation of this utility class.
     */
    private CliArgs() {
    }

    /**
     * Parses command-line arguments into a structured configuration object.
     *
     * <p>
     * If a {@code -config <file>} argument is present it is loaded first and
     * its values seed the initial configuration. Subsequent command-line flags
     * override those defaults.
     * </p>
     *
     * <p>
     * <b>SARIF mode and security filtering:</b> when {@code -sarif} is selected
     * (or {@code outputMode: sarif} is set in YAML), the security-only filter is
     * applied automatically — only security-relevant methods are emitted. This
     * default exists because SARIF is consumed by GitHub Code Scanning and
     * equivalent security tooling that expects actionable security findings, not
     * an exhaustive inventory of all test methods. Use {@code -include-non-security}
     * to override this behaviour and include all methods in the SARIF document.
     * </p>
     *
     * <p>
     * The {@code -security-only} flag continues to work independently and applies
     * the same filter to CSV and plain-text output modes.
     * </p>
     *
     * @param args raw command-line arguments
     * @return parsed command-line configuration, or {@code null} when a
     *         validation error (for example {@code -emit-coverage} without
     *         {@code -coverage-mapping}) has already been reported to
     *         {@code stderr} and the caller should exit with a bad-arguments code
     * @throws IllegalArgumentException if an option value is missing, malformed,
     *                                  or unsupported, or if the config file
     *                                  cannot be read
     */
    @SuppressWarnings({"PMD.AvoidReassigningLoopVariables", "PMD.CyclomaticComplexity",
            "PMD.NPathComplexity", "PMD.NcssCount", "PMD.CognitiveComplexity"})
    /* default */ static CliConfig parse(String... args) {
        // Pre-scan for -config to load YAML defaults before processing other flags.
        YamlConfig.YamlConfigFile yamlConfig = loadYamlConfigFromArgs(args);

        // Seed initial values from YAML (command-line flags will override these).
        OutputMode outputMode = resolveOutputModeFromYaml(yamlConfig);
        boolean emitMetadata = yamlConfig != null && yamlConfig.emitMetadata;
        List<String> fileSuffixes = yamlConfig != null && yamlConfig.fileSuffixes != null
                ? new ArrayList<>(yamlConfig.fileSuffixes) : new ArrayList<>();
        Set<String> testMarkers = yamlConfig != null && yamlConfig.testMarkers != null
                ? new LinkedHashSet<>(yamlConfig.testMarkers) : new LinkedHashSet<>();
        Map<String, List<String>> properties = new LinkedHashMap<>();
        if (yamlConfig != null && yamlConfig.properties != null) {
            yamlConfig.properties.forEach((k, v) -> properties.put(k, new ArrayList<>(v)));
        }
        AiOptions.Builder aiBuilder = AiOptions.builder();
        if (yamlConfig != null && yamlConfig.ai != null) {
            applyYamlAiConfig(aiBuilder, yamlConfig.ai);
        }

        List<Path> paths = new ArrayList<>();
        String manualWorkDir = null;
        String manualResponseDir = null;
        boolean manualIsConsume = false;
        boolean applyTags = false;
        boolean contentHash = yamlConfig != null && yamlConfig.contentHash;
        Path overrideFilePath = yamlConfig != null && yamlConfig.overrideFile != null
                ? Paths.get(yamlConfig.overrideFile) : null;
        boolean securityOnly = yamlConfig != null && yamlConfig.securityOnly;
        boolean includeNonSecurity = yamlConfig != null && yamlConfig.includeNonSecurity;
        boolean sarifOmitScores = yamlConfig != null && yamlConfig.sarifOmitScores;
        double minConfidence = yamlConfig != null && yamlConfig.minConfidence != null
                ? yamlConfig.minConfidence : 0.0;
        boolean driftDetect = yamlConfig != null && yamlConfig.driftDetect;
        boolean emitSourceRoot = false;
        Path aiCacheFile = null;
        Path aiCacheOut = null;
        Path applyTagsFromCsvFile = null;
        int mismatchLimit = -1;
        boolean emitReceipt = false;
        Path receiptFile = null;
        boolean emitCoverage = false;
        Path coverageFile = null;
        Path coverageMappingFile = null;
        String evidencePackFramework = null;
        Path evidencePackDir = null;
        boolean evidencePackOverwrite = false;
        Path evidencePackKeyringFile = null;
        String evidencePackKeyringEnv = null;
        String evidencePackKeyAlias = null;
        String evidencePackSignAlgo = null;
        boolean verbose = false;
        // -promote-ai lets the apply-from-csv engine write unvalidated AI output
        // into source where curated columns are blank — risky and off by default.
        // Seeded from YAML (promoteAi:) and overridable by the CLI flag.
        boolean promoteAi = yamlConfig != null && yamlConfig.promoteAi;
        // Credential-detection flags — all off/null/default until -detect-secrets is given.
        boolean detectSecrets = yamlConfig != null && yamlConfig.detectSecrets;
        String secretsInclude = yamlConfig != null ? yamlConfig.secretsInclude : null;
        Path secretsRules = yamlConfig != null && yamlConfig.secretsRules != null
                ? Paths.get(yamlConfig.secretsRules) : null;
        Path secretsOut = yamlConfig != null && yamlConfig.secretsOut != null
                ? Paths.get(yamlConfig.secretsOut) : Path.of("methodatlas-credentials.csv");
        boolean secretsSeparateLlm = yamlConfig != null && yamlConfig.secretsSeparateLlm;
        boolean secretsShowValues = yamlConfig != null && yamlConfig.secretsShowValues;
        double secretsErrorThreshold = yamlConfig != null && yamlConfig.secretsErrorThreshold != null
                ? yamlConfig.secretsErrorThreshold : 0.8;
        double secretsWarningThreshold = yamlConfig != null && yamlConfig.secretsWarningThreshold != null
                ? yamlConfig.secretsWarningThreshold : 0.4;
        double secretsMinScore = yamlConfig != null && yamlConfig.secretsMinScore != null
                ? yamlConfig.secretsMinScore : 0.0;
        // Optional prompt-template override files (CLI overrides YAML). Resolved into
        // an effective PromptTemplateSet after parsing, validated fail-fast.
        Path classificationPromptFile = yamlAiPromptPath(yamlConfig, "classification");
        Path triagePromptFile = yamlAiPromptPath(yamlConfig, "triage");
        Path dedicatedTriagePromptFile = yamlAiPromptPath(yamlConfig, "dedicatedTriage");
        // Tracks whether the first CLI -file-suffix has been seen; when it is,
        // subsequent -file-suffix values are appended rather than replacing defaults.
        boolean cliFileSuffixSet = false;

        for (int i = 0; i < args.length; i++) {
            String arg = args[i];
            if (FLAG_AI_CACHE.equals(arg)) {
                aiCacheFile = Paths.get(nextArg(args, ++i, arg));
                continue;
            }
            if (FLAG_AI_CACHE_OUT.equals(arg)) {
                aiCacheOut = Paths.get(nextArg(args, ++i, arg));
                continue;
            }
            if (arg.startsWith("-ai")) {
                i = applyAiArg(arg, args, i, aiBuilder);
                continue;
            }
            switch (arg) {
                case "-plain" -> outputMode = OutputMode.PLAIN;
                case "-sarif" -> outputMode = OutputMode.SARIF;
                case "-json" -> outputMode = OutputMode.JSON;
                case "-github-annotations" -> outputMode = OutputMode.GITHUB_ANNOTATIONS;
                case "-apply-tags" -> applyTags = true;
                case FLAG_APPLY_TAGS_FROM_CSV -> applyTagsFromCsvFile = Paths.get(nextArg(args, ++i, arg));
                case FLAG_PROMOTE_AI -> promoteAi = true;
                case FLAG_MISMATCH_LIMIT -> mismatchLimit = Integer.parseInt(nextArg(args, ++i, arg));
                case "-content-hash" -> contentHash = true;
                case FLAG_CONFIG -> i++; // value already consumed in pre-scan; skip here
                case "-file-suffix" -> {
                    if (!cliFileSuffixSet) {
                        // First CLI -file-suffix replaces YAML defaults
                        fileSuffixes.clear();
                        cliFileSuffixSet = true;
                    }
                    fileSuffixes.add(nextArg(args, ++i, arg));
                }
                case "-test-marker", "-test-annotation" -> testMarkers.add(nextArg(args, ++i, arg));
                case "-property" -> {
                    String kv = nextArg(args, ++i, arg);
                    int eq = kv.indexOf('=');
                    if (eq < 0) {
                        throw new IllegalArgumentException(
                                "Invalid -property value: '" + kv + "'; expected key=value format");
                    }
                    properties.computeIfAbsent(kv.substring(0, eq), k -> new ArrayList<>()) // NOPMD - one list per unique key, not per iteration
                            .add(kv.substring(eq + 1));
                }
                case "-emit-metadata" -> emitMetadata = true;
                case "-security-only" -> securityOnly = true;
                case FLAG_INCLUDE_NON_SECURITY -> includeNonSecurity = true;
                case FLAG_SARIF_OMIT_SCORES -> sarifOmitScores = true;
                case FLAG_DRIFT_DETECT -> driftDetect = true;
                case FLAG_EMIT_SOURCE_ROOT -> emitSourceRoot = true;
                case "-verbose" -> verbose = true;
                case FLAG_MIN_CONFIDENCE -> minConfidence = parseConfidenceThreshold(nextArg(args, ++i, arg));
                case "-override-file" -> overrideFilePath = Paths.get(nextArg(args, ++i, arg));
                case FLAG_EMIT_RECEIPT -> emitReceipt = true;
                case FLAG_RECEIPT_FILE -> {
                    String value = nextArg(args, ++i, arg);
                    if (value.isBlank()) {
                        throw new IllegalArgumentException(
                                "-receipt-file path must not be blank");
                    }
                    receiptFile = Paths.get(value);
                }
                case FLAG_EMIT_COVERAGE -> emitCoverage = true;
                case FLAG_COVERAGE_FILE -> {
                    String value = nextArg(args, ++i, arg);
                    if (value.isBlank()) {
                        throw new IllegalArgumentException(
                                "-coverage-file path must not be blank");
                    }
                    coverageFile = Paths.get(value);
                }
                case FLAG_COVERAGE_MAPPING -> {
                    String value = nextArg(args, ++i, arg);
                    if (value.isBlank()) {
                        throw new IllegalArgumentException(
                                "-coverage-mapping path must not be blank");
                    }
                    coverageMappingFile = Paths.get(value);
                }
                case FLAG_EVIDENCE_PACK -> evidencePackFramework = nextArg(args, ++i, arg);
                case FLAG_EVIDENCE_PACK_DIR -> evidencePackDir = Paths.get(nextArg(args, ++i, arg));
                case FLAG_EVIDENCE_PACK_OVERWRITE -> evidencePackOverwrite = true;
                case FLAG_EVIDENCE_PACK_KEYRING ->
                    evidencePackKeyringFile = Paths.get(nextArg(args, ++i, arg));
                case FLAG_EVIDENCE_PACK_KEYRING_ENV -> evidencePackKeyringEnv = nextArg(args, ++i, arg);
                case FLAG_EVIDENCE_PACK_KEY_ALIAS -> evidencePackKeyAlias = nextArg(args, ++i, arg);
                case FLAG_EVIDENCE_PACK_SIGN_ALGO -> evidencePackSignAlgo = nextArg(args, ++i, arg);
                case FLAG_DETECT_SECRETS -> detectSecrets = true;
                case FLAG_SECRETS_INCLUDE -> secretsInclude = nextArg(args, ++i, arg);
                case FLAG_SECRETS_RULES -> secretsRules = Paths.get(nextArg(args, ++i, arg));
                case FLAG_SECRETS_OUT -> secretsOut = Paths.get(nextArg(args, ++i, arg));
                case FLAG_SECRETS_SEPARATE_LLM -> secretsSeparateLlm = true;
                case FLAG_SECRETS_SHOW_VALUES -> secretsShowValues = true;
                case FLAG_SECRETS_ERROR_THRESHOLD ->
                    secretsErrorThreshold = Double.parseDouble(nextArg(args, ++i, arg));
                case FLAG_SECRETS_WARNING_THRESHOLD ->
                    secretsWarningThreshold = Double.parseDouble(nextArg(args, ++i, arg));
                case FLAG_SECRETS_MIN_SCORE ->
                    secretsMinScore = Double.parseDouble(nextArg(args, ++i, arg));
                case FLAG_CLASSIFICATION_PROMPT -> classificationPromptFile = Paths.get(nextArg(args, ++i, arg));
                case FLAG_TRIAGE_PROMPT -> triagePromptFile = Paths.get(nextArg(args, ++i, arg));
                case FLAG_DEDICATED_TRIAGE_PROMPT ->
                    dedicatedTriagePromptFile = Paths.get(nextArg(args, ++i, arg));
                case "-manual-prepare" -> {
                    manualWorkDir = nextArg(args, ++i, arg);
                    manualResponseDir = nextArg(args, ++i, arg);
                    manualIsConsume = false;
                }
                case "-manual-consume" -> {
                    manualWorkDir = nextArg(args, ++i, arg);
                    manualResponseDir = nextArg(args, ++i, arg);
                    manualIsConsume = true;
                }
                default -> {
                    if (arg.startsWith("-")) {
                        throw new IllegalArgumentException("Unknown argument: " + arg);
                    }
                    paths.add(Paths.get(arg));
                }
            }
        }

        ManualMode manualMode = null;
        if (manualWorkDir != null) {
            Path workDir = Paths.get(manualWorkDir);
            Path responseDir = Paths.get(manualResponseDir);
            manualMode = manualIsConsume
                    ? new ManualMode.Consume(workDir, responseDir)
                    : new ManualMode.Prepare(workDir, responseDir);
        }

        // SARIF is consumed by security tooling that expects findings, not a full
        // test inventory. Apply the security-only filter implicitly unless the
        // caller has explicitly opted in to the full-inventory form.
        if (outputMode == OutputMode.SARIF && !includeNonSecurity) {
            securityOnly = true;
        }

        // Coverage mode requires a user-authored mapping file; the tool ships no
        // built-in mapping. Reject the request with a structured stderr message
        // pointing at the reference template; the caller (MethodAtlasApp) maps
        // the null return value to exit code 2.
        if (emitCoverage && coverageMappingFile == null) {
            System.err.println("Error: -emit-coverage requires -coverage-mapping <path>.");
            System.err.println("A reference template and authoring guide are available at:");
            System.err.println("  docs/examples/asvs4-mapping.json");
            System.err.println("  docs/usage-modes/control-coverage.md");
            return null;
        }

        // Resolve and validate any prompt-template overrides, then hand the effective
        // set to the AI options so both prompt rendering and receipt hashing use it.
        aiBuilder.promptTemplates(
                resolvePromptTemplates(classificationPromptFile, triagePromptFile, dedicatedTriagePromptFile));

        List<String> resolvedSuffixes = fileSuffixes.isEmpty() ? List.of(DEFAULT_FILE_SUFFIX) : fileSuffixes;
        Set<String> resolvedMarkers = testMarkers.isEmpty() ? Set.of() : testMarkers;
        return new CliConfig(outputMode, aiBuilder.build(), paths, resolvedSuffixes, resolvedMarkers,
                Map.copyOf(properties), emitMetadata, manualMode, applyTags, contentHash, overrideFilePath,
                securityOnly, aiCacheFile, driftDetect, applyTagsFromCsvFile, mismatchLimit, emitSourceRoot,
                sarifOmitScores, minConfidence, emitReceipt, receiptFile,
                emitCoverage, coverageFile, coverageMappingFile,
                evidencePackFramework, evidencePackDir,
                evidencePackOverwrite, evidencePackKeyringFile, evidencePackKeyringEnv,
                evidencePackKeyAlias, evidencePackSignAlgo, verbose, promoteAi,
                detectSecrets, secretsInclude, secretsRules,
                secretsOut, secretsSeparateLlm, secretsShowValues,
                secretsErrorThreshold, secretsWarningThreshold, secretsMinScore,
                aiCacheOut);
    }

    // -------------------------------------------------------------------------
    // YAML config helpers
    // -------------------------------------------------------------------------

    /**
     * Pre-scans {@code args} for a {@code -config <file>} argument and loads the
     * YAML file if found.
     *
     * @param args raw command-line arguments
     * @return parsed YAML config, or {@code null} when no {@code -config} flag is
     *         present
     * @throws IllegalArgumentException if the config file cannot be read
     */
    private static YamlConfig.YamlConfigFile loadYamlConfigFromArgs(String... args) {
        for (int i = 0; i < args.length - 1; i++) {
            if (FLAG_CONFIG.equals(args[i])) {
                Path configPath = Paths.get(args[i + 1]);
                try {
                    return YamlConfig.load(configPath);
                } catch (IOException e) {
                    throw new IllegalArgumentException("Cannot load config file: " + configPath, e);
                }
            }
        }
        return null;
    }

    /**
     * Derives the initial {@link OutputMode} from a loaded YAML config.
     *
     * @param yamlConfig YAML config, or {@code null}
     * @return resolved output mode; defaults to {@link OutputMode#CSV}
     */
    private static OutputMode resolveOutputModeFromYaml(YamlConfig.YamlConfigFile yamlConfig) {
        if (yamlConfig == null || yamlConfig.outputMode == null) {
            return OutputMode.CSV;
        }
        return switch (yamlConfig.outputMode.toLowerCase(Locale.ROOT)) {
            case "plain" -> OutputMode.PLAIN;
            case "sarif" -> OutputMode.SARIF;
            case "json" -> OutputMode.JSON;
            default -> OutputMode.CSV;
        };
    }

    /**
     * Seeds the AI options builder from YAML configuration values.
     *
     * @param builder   AI options builder to update
     * @param aiConfig  AI section of the YAML config; never {@code null}
     */
    @SuppressWarnings("PMD.NPathComplexity")
    private static void applyYamlAiConfig(AiOptions.Builder builder, YamlConfig.YamlAiConfig aiConfig) {
        if (Boolean.TRUE.equals(aiConfig.enabled)) {
            builder.enabled(true);
        }
        if (aiConfig.provider != null) {
            builder.provider(AiProvider.valueOf(aiConfig.provider.toUpperCase(Locale.ROOT)));
        }
        if (aiConfig.model != null) {
            builder.modelName(aiConfig.model);
        }
        if (aiConfig.baseUrl != null) {
            builder.baseUrl(aiConfig.baseUrl);
        }
        if (aiConfig.apiKey != null) {
            builder.apiKey(aiConfig.apiKey);
        }
        if (aiConfig.apiKeyEnv != null) {
            builder.apiKeyEnv(aiConfig.apiKeyEnv);
        }
        if (aiConfig.taxonomyFile != null) {
            builder.taxonomyFile(Paths.get(aiConfig.taxonomyFile));
        }
        if (aiConfig.taxonomyMode != null) {
            builder.taxonomyMode(
                    AiOptions.TaxonomyMode.valueOf(aiConfig.taxonomyMode.toUpperCase(Locale.ROOT)));
        }
        if (aiConfig.maxClassChars != null) {
            builder.maxClassChars(aiConfig.maxClassChars);
        }
        if (aiConfig.timeoutSec != null) {
            builder.timeout(Duration.ofSeconds(aiConfig.timeoutSec));
        }
        if (aiConfig.maxRetries != null) {
            builder.maxRetries(aiConfig.maxRetries);
        }
        if (Boolean.TRUE.equals(aiConfig.confidence)) {
            builder.confidence(true);
        }
        if (aiConfig.apiVersion != null) {
            builder.apiVersion(aiConfig.apiVersion);
        }
    }

    /**
     * Reads an AI prompt-template override path from the YAML config, if present.
     *
     * @param yamlConfig loaded YAML config, or {@code null}
     * @param which      one of {@code "classification"}, {@code "triage"},
     *                   {@code "dedicatedTriage"}
     * @return the configured path, or {@code null} when unset
     */
    private static Path yamlAiPromptPath(YamlConfig.YamlConfigFile yamlConfig, String which) {
        if (yamlConfig == null || yamlConfig.ai == null) {
            return null;
        }
        String value = switch (which) {
            case "classification" -> yamlConfig.ai.classificationPrompt;
            case "triage" -> yamlConfig.ai.triagePrompt;
            case "dedicatedTriage" -> yamlConfig.ai.dedicatedTriagePrompt;
            default -> null;
        };
        return value == null ? null : Paths.get(value);
    }

    /**
     * Builds the effective {@link PromptTemplateSet} from the built-in defaults plus
     * any operator overrides, validating each override fail-fast.
     *
     * @param classification  classification template file, or {@code null}
     * @param triageAppendix   folded triage-appendix template file, or {@code null}
     * @param dedicatedTriage standalone triage template file, or {@code null}
     * @return the resolved template set; never {@code null}
     * @throws IllegalArgumentException if an override file cannot be read or fails
     *                                  structural validation
     */
    private static PromptTemplateSet resolvePromptTemplates(Path classification, Path triageAppendix,
            Path dedicatedTriage) {
        PromptTemplateSet set = PromptTemplateSet.defaults();
        set = applyPromptOverride(set, PromptTemplateKind.CLASSIFICATION, classification);
        set = applyPromptOverride(set, PromptTemplateKind.TRIAGE_APPENDIX, triageAppendix);
        set = applyPromptOverride(set, PromptTemplateKind.DEDICATED_TRIAGE, dedicatedTriage);
        return set;
    }

    /**
     * Loads, validates, and applies a single prompt-template override.
     *
     * @param set  the set to extend; must not be {@code null}
     * @param kind the kind the file overrides; must not be {@code null}
     * @param file the override file, or {@code null} to leave the kind unchanged
     * @return the (possibly extended) set; never {@code null}
     * @throws IllegalArgumentException if the file cannot be read or is invalid
     */
    private static PromptTemplateSet applyPromptOverride(PromptTemplateSet set, PromptTemplateKind kind, Path file) {
        if (file == null) {
            return set;
        }
        String body;
        try {
            body = Files.readString(file);
        } catch (IOException e) {
            throw new IllegalArgumentException("Cannot read prompt template " + file + ": " + e.getMessage(), e);
        }
        try {
            PromptTemplateValidator.validateOrThrow(kind, body, file.toString());
        } catch (PromptTemplateException e) {
            throw new IllegalArgumentException(e.getMessage(), e);
        }
        return set.with(kind, body);
    }

    // -------------------------------------------------------------------------
    // AI argument helper
    // -------------------------------------------------------------------------

    /**
     * Applies a single AI-related command-line argument to the builder.
     *
     * <p>
     * Handles all {@code -ai*} flags. Returns the updated argument index so
     * the caller's loop counter stays consistent when the flag consumes an
     * additional value token.
     * </p>
     *
     * @param arg     the flag token being processed
     * @param args    full argument array
     * @param i       current position in {@code args}
     * @param builder AI options builder to update
     * @return updated value of {@code i} after consuming any argument value
     * @throws IllegalArgumentException if a required value token is missing or
     *                                  the flag is unrecognised
     */
    private static int applyAiArg(String arg, String[] args, int i, AiOptions.Builder builder) {
        int idx = i;
        switch (arg) {
            case "-ai" -> builder.enabled(true);
            case "-ai-confidence" -> builder.confidence(true);
            case "-ai-provider" ->
                builder.provider(AiProvider.valueOf(nextArg(args, ++idx, arg).toUpperCase(Locale.ROOT)));
            case "-ai-model" -> builder.modelName(nextArg(args, ++idx, arg));
            case "-ai-base-url" -> builder.baseUrl(nextArg(args, ++idx, arg));
            case "-ai-api-key" -> builder.apiKey(nextArg(args, ++idx, arg));
            case "-ai-api-key-env" -> builder.apiKeyEnv(nextArg(args, ++idx, arg));
            case "-ai-taxonomy" -> builder.taxonomyFile(Paths.get(nextArg(args, ++idx, arg)));
            case "-ai-taxonomy-mode" ->
                builder.taxonomyMode(
                        AiOptions.TaxonomyMode.valueOf(nextArg(args, ++idx, arg).toUpperCase(Locale.ROOT)));
            case "-ai-max-class-chars" -> builder.maxClassChars(Integer.parseInt(nextArg(args, ++idx, arg)));
            case "-ai-timeout-sec" ->
                builder.timeout(Duration.ofSeconds(Long.parseLong(nextArg(args, ++idx, arg))));
            case "-ai-max-retries" -> builder.maxRetries(Integer.parseInt(nextArg(args, ++idx, arg)));
            case "-ai-api-version" -> builder.apiVersion(nextArg(args, ++idx, arg));
            default -> throw new IllegalArgumentException("Unknown AI argument: " + arg);
        }
        return idx;
    }

    /**
     * Validates and parses the {@code -min-confidence} argument value.
     *
     * @param value raw string value from the command line
     * @return parsed confidence threshold in the range {@code [0.0, 1.0]}
     * @throws IllegalArgumentException if the value is outside {@code [0.0, 1.0]}
     */
    private static double parseConfidenceThreshold(String value) {
        double parsed = Double.parseDouble(value);
        if (parsed < 0.0 || parsed > 1.0) {
            throw new IllegalArgumentException(
                    "-min-confidence value must be between 0.0 and 1.0, got: " + parsed);
        }
        return parsed;
    }

    /**
     * Returns the argument value following an option token.
     *
     * @param args   full command-line argument array
     * @param index  index of the expected option value
     * @param option option whose value is being retrieved
     * @return argument value at {@code index}
     * @throws IllegalArgumentException if {@code index} is outside the bounds of
     *                                  {@code args}
     */
    private static String nextArg(String[] args, int index, String option) {
        if (index >= args.length) {
            throw new IllegalArgumentException("Missing value for " + option);
        }
        return args[index];
    }
}