import yargs from "yargs/yargs";
import { hideBin } from "yargs/helpers";
import { validate } from "./common/src/validate.js";
import { resolvePaths, readFile } from "./core/index.js";
import { getRunOutputDir } from "./core/utils.js";
import path from "node:path";
import fs from "node:fs";
import { spawn } from "node:child_process";
import os from "node:os";
import axios from "axios";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
export { buildYargs, setArgs, setConfig, outputResults, spawnCommand, setMeta, getVersionData, log, getResolvedTestsFromEnv, reportResults, reporters, registerReporter, isDebugRequested, };
// True when `DOC_DETECTIVE_DEBUG` env var is set to a truthy value.
// Used by `runTestsHandler` in cli.ts to decide whether to catch
// setConfig failures and still emit a diagnostic dump.
//
// The `--debug` CLI flag was retired in favor of the dedicated
// `doc-detective debug` subcommand; the subcommand handler does its
// own setConfig-error handling, so this helper is now env-var-only.
function isDebugRequested() {
    const envVal = process.env.DOC_DETECTIVE_DEBUG;
    // Trim so a templated/copy-pasted value with stray whitespace (e.g.
    // `"true "`) still activates debug mode.
    return typeof envVal === "string" && /^(1|true|yes)$/i.test(envVal.trim());
}
// Log function that respects logLevel
function log(message, level = "info", config = {}) {
    const logLevels = ["silent", "error", "warning", "info", "debug"];
    const currentLevel = config.logLevel || "info";
    const currentLevelIndex = logLevels.indexOf(currentLevel);
    const messageLevelIndex = logLevels.indexOf(level);
    // Only log if the message level is at or above the current log level
    if (currentLevelIndex >= messageLevelIndex && messageLevelIndex > 0) {
        if (level === "error") {
            console.error(message);
        }
        else {
            console.log(message);
        }
    }
}
// Build a yargs instance with the global options shared across all subcommands.
// Kept as its own export so src/cli.ts can attach .command(...) registrations
// and route into subcommands, while setArgs() below preserves the legacy
// no-subcommand parse signature used by test/utils.test.js.
function buildYargs(args) {
    return yargs(hideBin(args))
        .option("config", {
        alias: "c",
        description: "Path to a `config.json` or `config.yaml` file.",
        type: "string",
    })
        .option("input", {
        alias: "i",
        description: "Path to test specifications and documentation source files. May be paths to specific files or to directories to scan for files.",
        type: "string",
    })
        .option("output", {
        alias: "o",
        description: "Path of the directory in which to store the output of Doc Detective commands.",
        type: "string",
    })
        .option("logLevel", {
        alias: "l",
        description: "Detail level of logging events. Accepted values: silent, error, warning, info (default), debug",
        type: "string",
    })
        .option("allow-unsafe", {
        description: "Allow execution of potentially unsafe tests",
        type: "boolean",
    })
        .option("dry-run", {
        alias: "n",
        description: "Resolve tests fully (file detection, inline-test extraction, config merge) and emit the resolved test plan as JSON, but do not execute any steps.",
        type: "boolean",
    })
        .option("reporters", {
        alias: "r",
        description: "Reporters to use for output. Built-in reporters: terminal, json, html, runFolder (archives results in <output>/.doc-detective/run-<runId>/, beside any screenshots from the run). Custom reporters registered via registerReporter() can also be referenced by name. Pass multiple values after the flag (e.g. --reporters terminal html) or repeat the flag (e.g. -r terminal -r html).",
        type: "string",
        array: true,
    })
        .option("test", {
        alias: "t",
        description: "Run only tests whose testId matches the given regex (case-insensitive). Comma-separate multiple patterns; a test passes if any pattern matches.",
        type: "string",
    })
        .option("spec", {
        alias: "s",
        description: "Run only specs whose specId matches the given regex (case-insensitive). Comma-separate multiple patterns; a spec passes if any pattern matches.",
        type: "string",
    })
        .option("hints", {
        description: "After a test run, show one applicable post-run hint with code samples and links. Some hints intentionally fire on failures (e.g. enableDebugLog). Disable with --no-hints.",
        type: "boolean",
    })
        .option("auto-screenshot", {
        description: "Capture a screenshot after every browser-based step. Images are saved in the per-run artifact folder (<output>/.doc-detective/run-<runId>/) at paths derived from spec/test/context IDs plus each step's order, action, and ID, so the same step lands on the same relative path in every run's folder for run-over-run comparison. Use --no-auto-screenshot to override a config file that enables it.",
        type: "boolean",
    })
        .option("auto-update", {
        description: "Check the npm registry on startup for a newer doc-detective and self-update before running. Use --no-auto-update (or set autoUpdate: false in config) to pin to the installed version.",
        type: "boolean",
    })
        .option("cache-dir", {
        description: "Directory for lazy-installed runtime assets (heavy npm packages, browser binaries, ffmpeg). Defaults to <os.tmpdir()>/doc-detective/. Also overridable via DOC_DETECTIVE_CACHE_DIR.",
        type: "string",
    })
        .option("concurrent-runners", {
        description: "Number of test contexts to run concurrently (default 1). Pass a number (for example, --concurrent-runners 4), or pass the flag with no value to use the CPU core count (capped at 4). With more than 1 runner, log lines from different contexts interleave and cross-context variable dependencies are unsupported.",
        type: "string",
    })
        .version(require("../package.json").version)
        .help()
        .alias("help", "h");
}
// Define args
function setArgs(args) {
    if (!args)
        return {};
    return buildYargs(args).argv;
}
// Get resolved tests from environment variable, if set
async function getResolvedTestsFromEnv(config = {}) {
    if (!process.env.DOC_DETECTIVE_API) {
        return null;
    }
    let resolvedTests = null;
    let apiConfig = null;
    try {
        // Parse the environment variable as JSON
        apiConfig = JSON.parse(process.env.DOC_DETECTIVE_API);
        // Validate the structure: { accountId, url, token, contextIds }
        if (!apiConfig.accountId || !apiConfig.url || !apiConfig.token || !apiConfig.contextIds) {
            log("Invalid DOC_DETECTIVE_API: must contain 'accountId', 'url', 'token', and 'contextIds' properties", "error", config);
            process.exit(1);
        }
        log(`CLI:Fetching resolved tests from ${apiConfig.url}/resolved-tests`, "debug", config);
        // Make GET request to the specified URL with token in header
        const response = await axios.get(`${apiConfig.url}/resolved-tests`, {
            headers: {
                "x-runner-token": apiConfig.token,
            },
        });
        // The response is the resolvedTests
        resolvedTests = response.data;
        // Validate against resolvedTests_v3 schema
        const validation = validate({
            schemaKey: "resolvedTests_v3",
            object: resolvedTests,
        });
        if (!validation.valid) {
            log("Invalid resolvedTests from API response. " + validation.errors, "error", config);
            process.exit(1);
        }
        // Get config from environment variable for merging
        const envConfig = await getConfigFromEnv();
        if (envConfig) {
            // Apply config overrides to resolvedTests.config
            if (resolvedTests.config) {
                resolvedTests.config = { ...resolvedTests.config, ...envConfig };
            }
            else {
                resolvedTests.config = envConfig;
            }
        }
        log(`CLI:RESOLVED_TESTS:\n${JSON.stringify(resolvedTests, null, 2)}`, "debug", config);
    }
    catch (error) {
        log(`Error fetching resolved tests from DOC_DETECTIVE_API: ${error.message}`, "error", config);
        process.exit(1);
    }
    return { apiConfig, resolvedTests };
}
async function getConfigFromEnv() {
    if (!process.env.DOC_DETECTIVE_CONFIG) {
        return null;
    }
    let envConfig = null;
    try {
        // Parse the environment variable as JSON
        envConfig = JSON.parse(process.env.DOC_DETECTIVE_CONFIG);
        // JSON.parse legally yields non-objects (null, arrays, primitives).
        // validate() would then throw a generic "Object is required." that
        // doesn't point at the env var — give a targeted message instead.
        if (envConfig === null || typeof envConfig !== "object" || Array.isArray(envConfig)) {
            throw new Error("DOC_DETECTIVE_CONFIG environment variable must be a JSON object.");
        }
        // Validate the environment variable config
        const envValidation = validate({
            schemaKey: "config_v3",
            object: envConfig,
        });
        if (!envValidation.valid) {
            throw new Error(`Invalid config from DOC_DETECTIVE_CONFIG environment variable. ${envValidation.errors}`);
        }
        log(`CLI:ENV_CONFIG:\n${JSON.stringify(envConfig, null, 2)}`, "debug", envConfig);
    }
    catch (error) {
        if (error instanceof SyntaxError) {
            throw new Error(`Error parsing DOC_DETECTIVE_CONFIG environment variable: ${error.message}`);
        }
        throw error;
    }
    return envConfig;
}
// Override config values based on args and validate the config
async function setConfig({ configPath, args }) {
    if (args.config && !configPath) {
        configPath = args.config;
    }
    // If a config path is given, read it. `readFile` returns null on a read
    // failure (missing/unreadable file) and may throw on bad input; in both
    // cases throw a clear error rather than letting a null config fall
    // through to a generic "Object is required." validation message. Throwing
    // (rather than returning null) means an unreadable / mistyped config path
    // follows the same error path as a validation failure: the normal run
    // exits non-zero via the top-level catch, and the debug flows render it
    // under the CONFIG INVALID banner.
    let config = {};
    if (configPath) {
        try {
            config = await readFile({ fileURLOrPath: configPath });
        }
        catch (error) {
            throw new Error(`Error reading config file at ${configPath}: ${error?.message || error}`);
        }
        if (config === null || config === undefined) {
            throw new Error(`Could not read config file at ${configPath}. Check that the path exists and is readable.`);
        }
    }
    // Check for DOC_DETECTIVE_CONFIG environment variable
    const envConfig = await getConfigFromEnv();
    if (envConfig) {
        // Merge with file config, preferring environment variable config (use raw envConfig, not validated with defaults)
        config = { ...config, ...envConfig };
    }
    // Validate config
    const validation = validate({
        schemaKey: "config_v3",
        object: config,
    });
    if (!validation.valid) {
        throw new Error(`Invalid config. ${validation.errors}`);
    }
    // Accept coerced and defaulted values
    config = validation.object;
    // Set default values
    config = {
        ...config,
        input: config.input || ".",
        output: config.output || ".",
        recursive: config.recursive ?? true,
        relativePathBase: config.relativePathBase || "file",
        loadVariables: config.loadVariables || ".env",
        detectSteps: config.detectSteps ?? true,
        logLevel: config.logLevel || "info",
        fileTypes: config.fileTypes || ["markdown", "asciidoc", "html"],
        telemetry: config.telemetry || { send: true },
        hints: config.hints || { enabled: true },
    };
    // Override config values
    if (configPath) {
        config.configPath = configPath;
    }
    if (args.input) {
        // If input includes commas, split it into an array
        args.input = args.input.split(",").map((item) => item.trim());
        // Resolve paths
        args.input = args.input.map((item) => {
            if (item.startsWith("https://") || item.startsWith("http://")) {
                return item; // Don't resolve URLs
            }
            return path.resolve(item);
        });
        // Add to config
        config.input = args.input;
    }
    if (args.output) {
        config.output = path.resolve(args.output);
    }
    if (args.logLevel) {
        config.logLevel = args.logLevel;
    }
    if (typeof args.allowUnsafe === "boolean") {
        config.allowUnsafeSteps = args.allowUnsafe;
    }
    if (typeof args.dryRun === "boolean") {
        config.dryRun = args.dryRun;
    }
    if (args.reporters != null) {
        const reporterList = Array.isArray(args.reporters)
            ? args.reporters
            : [args.reporters];
        if (reporterList.length > 0) {
            config.reporters = reporterList.map((r) => String(r));
        }
    }
    if (typeof args.test === "string" && args.test.length > 0) {
        const list = args.test
            .split(",")
            .map((s) => s.trim())
            .filter((s) => s.length > 0);
        if (list.length > 0)
            config.testFilter = list;
    }
    if (typeof args.spec === "string" && args.spec.length > 0) {
        const list = args.spec
            .split(",")
            .map((s) => s.trim())
            .filter((s) => s.length > 0);
        if (list.length > 0)
            config.specFilter = list;
    }
    if (typeof args.hints === "boolean") {
        config.hints = config.hints || { enabled: true };
        config.hints.enabled = args.hints;
    }
    if (typeof args.autoUpdate === "boolean") {
        config.autoUpdate = args.autoUpdate;
    }
    if (typeof args.autoScreenshot === "boolean") {
        config.autoScreenshot = args.autoScreenshot;
    }
    if (typeof args.cacheDir === "string" && args.cacheDir.length > 0) {
        // Assignment-only per the documented setConfig contract (the standard
        // `length > 0` guard, no per-flag sanitization). getCacheDir() in
        // runtime/cacheDir.ts trims and drops whitespace-only values, so a
        // `--cache-dir "   "` override is neutralized at the consumption site.
        config.cacheDir = args.cacheDir;
    }
    if (typeof args.concurrentRunners === "string") {
        // The bare flag ("" after yargs string parsing) and an explicit "true"
        // select CPU-count mode; anything else must parse as a positive integer.
        // CLI overrides land after the config_v3 validation above, and the
        // pre-resolved-tests API path never re-validates, so a garbage value
        // would otherwise reach the runner as NaN and silently degrade to
        // sequential execution — warn and keep the validated value instead.
        if (args.concurrentRunners === "" ||
            args.concurrentRunners.toLowerCase() === "true") {
            config.concurrentRunners = true;
        }
        else {
            const count = Number(args.concurrentRunners);
            if (Number.isInteger(count) && count >= 1) {
                config.concurrentRunners = count;
            }
            else {
                log(`Ignoring invalid --concurrent-runners value '${args.concurrentRunners}'. Expected a positive integer, or no value (or 'true') for CPU-count mode.`, "warning", config);
            }
        }
    }
    // Resolve paths
    config = await resolvePaths({
        config: config,
        object: config,
        filePath: configPath || ".",
        nested: false,
        objectType: "config",
    });
    return config;
}
// Internal reporters
const reporters = {
    // HTML reporter: outputs results as a self-contained HTML file
    // Lazy-loaded to avoid parsing the large inlined CSS/JS on every CLI invocation
    htmlReporter: async (config, outputPath, results, options = {}) => {
        const { htmlReporter } = await import("./reporters/htmlReporter.js");
        return htmlReporter(config, outputPath, results, options);
    },
    // JSON reporter: outputs results to a JSON file
    jsonReporter: async (config = {}, outputPath, results, options = {}) => {
        // Define supported output extensions
        const outputExtensions = [".json"];
        // Normalize output path
        outputPath = path.resolve(outputPath);
        const data = JSON.stringify(results, null, 2);
        let outputFile = "";
        let outputDir = "";
        let reportType = "doc-detective-results";
        if (options.command) {
            if (options.command === "runCoverage") {
                reportType = "coverageResults";
            }
            else if (options.command === "runTests") {
                reportType = "testResults";
            }
        }
        // Detect if output ends with a supported extension
        if (outputExtensions.some((ext) => outputPath.endsWith(ext))) {
            outputDir = path.dirname(outputPath);
            outputFile = outputPath;
            // If outputFile already exists, add a counter to the filename
            if (fs.existsSync(outputFile)) {
                let counter = 0;
                while (fs.existsSync(outputFile.replace(".json", `-${counter}.json`))) {
                    counter++;
                }
                outputFile = outputFile.replace(".json", `-${counter}.json`);
            }
        }
        else {
            outputDir = outputPath;
            outputFile = path.resolve(outputDir, `${reportType}-${Date.now()}.json`);
        }
        try {
            // Create output directory if it doesn't exist
            if (!fs.existsSync(outputDir)) {
                fs.mkdirSync(outputDir, { recursive: true });
            }
            // Write results to output file
            fs.writeFileSync(outputFile, data);
            console.log(`See detailed results at ${outputFile}\n`);
            return outputFile;
        }
        catch (err) {
            console.error(`Error writing results to ${outputFile}. ${err}`);
            return null;
        }
    },
    // runFolder reporter: archives each run's results in its per-run artifact
    // folder (`<output>/.doc-detective/run-<runId>/`), beside any screenshots
    // captured during the run. The folder is the timestamp, so the filename
    // stays constant across runs — diff two run folders to compare results
    // over time. Runs by default in addition to the flat json reporter.
    runFolderReporter: async (config = {}, outputPath, results, options = {}) => {
        let reportType = "doc-detective-results";
        if (options.command === "runCoverage") {
            reportType = "coverageResults";
        }
        else if (options.command === "runTests") {
            reportType = "testResults";
        }
        // Prefer the run folder stamped on the results (set by runSpecs, shared
        // with auto screenshots) — but only when it resolves inside this output
        // path's `.doc-detective/` root. Results can originate outside the local
        // process (e.g. API runs), and a garbled or malicious runDir must not
        // redirect the write elsewhere. Otherwise derive a fresh run folder.
        const expectedRunsRoot = (() => {
            let base = outputPath || config.output || ".";
            const reportFileExtensions = [".json", ".html", ".htm"];
            if (reportFileExtensions.some((ext) => String(base).toLowerCase().endsWith(ext))) {
                base = path.dirname(base);
            }
            return path.resolve(base, ".doc-detective");
        })();
        const stampedRunDir = typeof results?.runDir === "string" && results.runDir.length > 0
            ? path.resolve(results.runDir)
            : null;
        // Confine the stamped runDir to the output's `.doc-detective/` root.
        // Compare *real* paths (resolving symlinks on both sides) so a runDir
        // that is — or sits under — a symlink pointing outside the tree can't
        // slip past a plain string-prefix check and redirect the write. Both
        // paths exist in the normal local case (getRunOutputDir created them);
        // a non-existent or unresolvable stamped path (e.g. an external/API
        // result) throws and falls through to a fresh local folder.
        let useStampedRunDir = false;
        if (stampedRunDir) {
            try {
                const realRoot = fs.realpathSync(expectedRunsRoot);
                const realStamped = fs.realpathSync(stampedRunDir);
                useStampedRunDir =
                    realStamped === realRoot ||
                        realStamped.startsWith(realRoot + path.sep);
            }
            catch {
                useStampedRunDir = false;
            }
        }
        const runDir = useStampedRunDir
            ? stampedRunDir
            : getRunOutputDir({ output: outputPath || config.output || "." });
        // When we reject the stamped runDir and write to a fresh folder, rewrite
        // runDir/runId in the archived JSON so the report describes the folder it
        // actually sits beside — otherwise consumers resolve `autoScreenshot`
        // paths against a directory the report no longer lives in.
        const persistedResults = useStampedRunDir
            ? results
            : {
                ...results,
                runDir,
                runId: path.basename(runDir).replace(/^run-/, ""),
            };
        const outputFile = path.resolve(runDir, `${reportType}.json`);
        try {
            fs.mkdirSync(runDir, { recursive: true });
            fs.writeFileSync(outputFile, JSON.stringify(persistedResults, null, 2));
            // Archive a human-readable HTML report beside the JSON, so the run folder
            // is a complete shareable artifact without the standalone `html` reporter.
            // Best-effort and isolated: an HTML failure must not break the JSON
            // archive (this reporter's primary contract). Dynamic import keeps the
            // large inlined CSS/JS off the hot path, matching htmlReporter.
            const htmlFile = path.resolve(runDir, `${reportType}.html`);
            try {
                const { buildHtml } = await import("./reporters/htmlReporter.js");
                fs.writeFileSync(htmlFile, buildHtml(persistedResults));
                console.log(`See per-run HTML report at ${htmlFile}\n`);
            }
            catch (htmlErr) {
                // Pass the error as a separate arg so its stack survives (string
                // interpolation would flatten it, e.g. to "[object Object]").
                console.error(`Error writing per-run HTML report to ${htmlFile}.`, htmlErr);
            }
            // Log the JSON path *after* the HTML line so that, within this reporter,
            // no "...report at <html>" line trails the per-run "results at" line. The
            // doc-detective GitHub Action resolves the results file by splitting
            // stdout on "results at " and require()-ing the trimmed last segment; a
            // trailing HTML line folded into that segment broke the release smoke
            // test. (Reporters run concurrently, so this line isn't guaranteed to be
            // the final stdout token overall — but every reporter's "results at" line
            // is itself a valid results path, so whichever lands last still resolves.)
            console.log(`See per-run results at ${outputFile}\n`);
            return outputFile;
        }
        catch (err) {
            console.error(`Error writing results to ${outputFile}. ${err}`);
            return null;
        }
    },
    // Terminal reporter: outputs a summary to the terminal
    terminalReporter: async (config = {}, outputPath, results, options = {}) => {
        // Defines colors for terminal output
        const colors = {
            red: "\x1b[31m",
            green: "\x1b[32m",
            yellow: "\x1b[33m",
            cyan: "\x1b[36m",
            reset: "\x1b[0m",
            bold: "\x1b[1m",
        };
        // Single source of truth for the "No tests were run" warning so the
        // null-results path and the totalTests===0 path can't drift apart.
        const printNoTestsWarning = () => {
            const specFilterActive = Array.isArray(config?.specFilter) && config.specFilter.length > 0;
            const testFilterActive = Array.isArray(config?.testFilter) && config.testFilter.length > 0;
            console.log(`\n${colors.yellow}⚠️  No tests were run. ⚠️${colors.reset}`);
            if (specFilterActive || testFilterActive) {
                console.log(`${colors.yellow}This may be because the configured filters excluded every spec/test. Filters: specFilter=${JSON.stringify(config?.specFilter ?? null)}, testFilter=${JSON.stringify(config?.testFilter ?? null)}.${colors.reset}`);
            }
            else {
                console.log(`${colors.yellow}Check that the input paths contain testable content.${colors.reset}`);
            }
        };
        // Check if we have the new results format with summary
        if (!results) {
            // No results means the runner bailed before producing a report (e.g.
            // no input matched, no tests detected upstream). Surface that as a
            // warning rather than the prior bare "No results available." line —
            // the run still exits 0, but it is empty, not a clean success.
            printNoTestsWarning();
            return;
        }
        // Handle results that have a summary section
        if (results.summary) {
            // Extract summary data
            const { specs, tests, contexts, steps } = results.summary;
            // Calculate totals
            const totalSpecs = specs
                ? specs.pass + specs.fail + specs.warning + specs.skipped
                : 0;
            const totalTests = tests
                ? tests.pass + tests.fail + tests.warning + tests.skipped
                : 0;
            const totalContexts = contexts
                ? contexts.pass + contexts.fail + contexts.warning + contexts.skipped
                : 0;
            const totalSteps = steps
                ? steps.pass + steps.fail + steps.warning + steps.skipped
                : 0;
            // Any failures overall?
            const hasFailures = (specs && specs.fail > 0) ||
                (tests && tests.fail > 0) ||
                (contexts && contexts.fail > 0) ||
                (steps && steps.fail > 0);
            // Any skipped overall?
            const allSpecsSkipped = specs && specs.pass === 0 && specs.fail === 0 && specs.skipped > 0;
            console.log(`\n${colors.bold}===== Doc Detective Results Summary =====${colors.reset}`);
            // Print specs summary if available
            if (specs) {
                console.log(`\n${colors.bold}Specs:${colors.reset}`);
                console.log(`Total: ${totalSpecs}`);
                if (specs.pass > 0) {
                    console.log(`${colors.green}Passed: ${specs.pass}${colors.reset}`);
                }
                else {
                    console.log(`Passed: ${specs.pass}`);
                }
                console.log(`${specs.fail > 0 ? colors.red : colors.green}Failed: ${specs.fail}${colors.reset}`);
                if (specs.warning > 0)
                    console.log(`${colors.yellow}Warnings: ${specs.warning}${colors.reset}`);
                if (specs.skipped > 0)
                    console.log(`${colors.yellow}Skipped: ${specs.skipped}${colors.reset}`);
            }
            // Print tests summary if available
            if (tests) {
                console.log(`\n${colors.bold}Tests:${colors.reset}`);
                console.log(`Total: ${totalTests}`);
                if (tests.pass > 0) {
                    console.log(`${colors.green}Passed: ${tests.pass}${colors.reset}`);
                }
                else {
                    console.log(`Passed: ${tests.pass}`);
                }
                console.log(`${tests.fail > 0 ? colors.red : colors.green}Failed: ${tests.fail}${colors.reset}`);
                if (tests.warning > 0)
                    console.log(`${colors.yellow}Warnings: ${tests.warning}${colors.reset}`);
                if (tests.skipped > 0)
                    console.log(`${colors.yellow}Skipped: ${tests.skipped}${colors.reset}`);
            }
            // Print contexts summary if available
            if (contexts) {
                console.log(`\n${colors.bold}Contexts:${colors.reset}`);
                console.log(`Total: ${totalContexts}`);
                if (contexts.pass > 0) {
                    console.log(`${colors.green}Passed: ${contexts.pass}${colors.reset}`);
                }
                else {
                    console.log(`Passed: ${contexts.pass}`);
                }
                console.log(`${contexts.fail > 0 ? colors.red : colors.green}Failed: ${contexts.fail}${colors.reset}`);
                if (contexts.warning > 0)
                    console.log(`${colors.yellow}Warnings: ${contexts.warning}${colors.reset}`);
                if (contexts.skipped > 0)
                    console.log(`${colors.yellow}Skipped: ${contexts.skipped}${colors.reset}`);
            }
            // Print steps summary if available
            if (steps) {
                console.log(`\n${colors.bold}Steps:${colors.reset}`);
                console.log(`Total: ${totalSteps}`);
                if (steps.pass > 0) {
                    console.log(`${colors.green}Passed: ${steps.pass}${colors.reset}`);
                }
                else {
                    console.log(`Passed: ${steps.pass}`);
                }
                console.log(`${steps.fail > 0 ? colors.red : colors.green}Failed: ${steps.fail}${colors.reset}`);
                if (steps.warning > 0)
                    console.log(`${colors.yellow}Warnings: ${steps.warning}${colors.reset}`);
                if (steps.skipped > 0)
                    console.log(`${colors.yellow}Skipped: ${steps.skipped}${colors.reset}`);
            }
            // If all specs were skipped, call it out
            if (allSpecsSkipped) {
                console.log(`\n${colors.yellow}⚠️  All items were skipped. No specs passed or failed. ⚠️${colors.reset}`);
            }
            // If zero tests ran for any reason (filters excluded everything, no
            // input matched, no tests detected, etc.), surface that as a warning
            // rather than letting the green "Passed: 0" line read like success.
            // The run still exits 0 — this is an empty run, not a failure.
            if (totalTests === 0) {
                printNoTestsWarning();
            }
            // If we have specs with failures, display them
            if (results.specs && hasFailures) {
                console.log(`\n${colors.bold}${colors.red}Failed Items:${colors.reset}`);
                // Collect failures
                const failedSpecs = [];
                const failedTests = [];
                const failedContexts = [];
                const failedSteps = [];
                // Collect skipped
                const skippedSpecs = [];
                const skippedTests = [];
                const skippedContexts = [];
                const skippedSteps = [];
                // Process specs array to collect failures and skipped
                results.specs.forEach((spec, specIndex) => {
                    // Check if spec has failed
                    if (spec.result === "FAIL") {
                        failedSpecs.push({
                            index: specIndex,
                            id: spec.specId || `Spec ${specIndex + 1}`,
                        });
                    }
                    // Check if spec was skipped
                    if (spec.result === "SKIPPED") {
                        skippedSpecs.push({
                            index: specIndex,
                            id: spec.specId || `Spec ${specIndex + 1}`,
                        });
                    }
                    // Process tests in this spec
                    if (spec.tests && spec.tests.length > 0) {
                        spec.tests.forEach((test, testIndex) => {
                            // Check if test has failed
                            if (test.result === "FAIL") {
                                failedTests.push({
                                    specIndex,
                                    testIndex,
                                    specId: spec.specId || `Spec ${specIndex + 1}`,
                                    id: test.testId || `Test ${testIndex + 1}`,
                                });
                            }
                            // Check if test was skipped
                            if (test.result === "SKIPPED") {
                                skippedTests.push({
                                    specIndex,
                                    testIndex,
                                    specId: spec.specId || `Spec ${specIndex + 1}`,
                                    id: test.testId || `Test ${testIndex + 1}`,
                                });
                            }
                            // Process contexts in this test
                            if (test.contexts && test.contexts.length > 0) {
                                test.contexts.forEach((context, contextIndex) => {
                                    // Check if context has failed
                                    if (context.result === "FAIL" ||
                                        (context.result && context.result.status === "FAIL")) {
                                        failedContexts.push({
                                            specIndex,
                                            testIndex,
                                            contextIndex,
                                            specId: spec.specId || `Spec ${specIndex + 1}`,
                                            testId: test.testId || `Test ${testIndex + 1}`,
                                            platform: context.platform || "unknown",
                                            browser: context.browser
                                                ? context.browser.name
                                                : "unknown",
                                        });
                                    }
                                    // Check if context was skipped
                                    if (context.result === "SKIPPED" ||
                                        (context.result && context.result.status === "SKIPPED")) {
                                        skippedContexts.push({
                                            specIndex,
                                            testIndex,
                                            contextIndex,
                                            specId: spec.specId || `Spec ${specIndex + 1}`,
                                            testId: test.testId || `Test ${testIndex + 1}`,
                                            platform: context.platform || "unknown",
                                            browser: context.browser
                                                ? context.browser.name
                                                : "unknown",
                                        });
                                    }
                                    // Process steps in this context
                                    if (context.steps && context.steps.length > 0) {
                                        context.steps.forEach((step, stepIndex) => {
                                            // Check if step has failed
                                            if (step.result === "FAIL") {
                                                failedSteps.push({
                                                    specIndex,
                                                    testIndex,
                                                    contextIndex,
                                                    stepIndex,
                                                    specId: spec.specId || `Spec ${specIndex + 1}`,
                                                    testId: test.testId || `Test ${testIndex + 1}`,
                                                    platform: context.platform || "unknown",
                                                    browser: context.browser
                                                        ? context.browser.name
                                                        : "unknown",
                                                    stepId: step.stepId || `Step ${stepIndex + 1}`,
                                                    error: step.resultDescription || "Unknown error",
                                                });
                                            }
                                            // Check if step was skipped
                                            if (step.result === "SKIPPED") {
                                                skippedSteps.push({
                                                    specIndex,
                                                    testIndex,
                                                    contextIndex,
                                                    stepIndex,
                                                    specId: spec.specId || `Spec ${specIndex + 1}`,
                                                    testId: test.testId || `Test ${testIndex + 1}`,
                                                    platform: context.platform || "unknown",
                                                    browser: context.browser
                                                        ? context.browser.name
                                                        : "unknown",
                                                    stepId: step.stepId || `Step ${stepIndex + 1}`,
                                                });
                                            }
                                        });
                                    }
                                });
                            }
                        });
                    }
                });
                // Display failures
                if (failedSpecs.length > 0) {
                    console.log(`\n${colors.red}Failed Specs:${colors.reset}`);
                    failedSpecs.forEach((item, i) => {
                        console.log(`${colors.red}${i + 1}. ${item.id}${colors.reset}`);
                    });
                }
                if (failedTests.length > 0) {
                    console.log(`\n${colors.red}Failed Tests:${colors.reset}`);
                    failedTests.forEach((item, i) => {
                        console.log(`${colors.red}${i + 1}. ${item.id} (from ${item.specId})${colors.reset}`);
                    });
                }
                if (failedContexts.length > 0) {
                    console.log(`\n${colors.red}Failed Contexts:${colors.reset}`);
                    failedContexts.forEach((item, i) => {
                        console.log(`${colors.red}${i + 1}. ${item.platform}/${item.browser} (from ${item.testId})${colors.reset}`);
                    });
                }
                if (failedSteps.length > 0) {
                    console.log(`\n${colors.red}Failed Steps:${colors.reset}`);
                    failedSteps.forEach((item, i) => {
                        console.log(`${colors.red}${i + 1}. ${item.platform}/${item.browser} - ${item.stepId}${colors.reset}`);
                        console.log(`   Error: ${item.error}`);
                    });
                }
                // Display skipped items in yellow
                if (skippedSpecs.length > 0) {
                    console.log(`\n${colors.yellow}Skipped Specs:${colors.reset}`);
                    skippedSpecs.forEach((item, i) => {
                        console.log(`${colors.yellow}${i + 1}. ${item.id}${colors.reset}`);
                    });
                }
                if (skippedTests.length > 0) {
                    console.log(`\n${colors.yellow}Skipped Tests:${colors.reset}`);
                    skippedTests.forEach((item, i) => {
                        console.log(`${colors.yellow}${i + 1}. ${item.id} (from ${item.specId})${colors.reset}`);
                    });
                }
                if (skippedContexts.length > 0) {
                    console.log(`\n${colors.yellow}Skipped Contexts:${colors.reset}`);
                    skippedContexts.forEach((item, i) => {
                        console.log(`${colors.yellow}${i + 1}. ${item.platform}/${item.browser} (from ${item.testId})${colors.reset}`);
                    });
                }
                if (skippedSteps.length > 0) {
                    console.log(`\n${colors.yellow}Skipped Steps:${colors.reset}`);
                    skippedSteps.forEach((item, i) => {
                        console.log(`${colors.yellow}${i + 1}. ${item.platform}/${item.browser} - ${item.stepId}${colors.reset}`);
                    });
                }
            }
            else if (!hasFailures && !allSpecsSkipped && totalTests > 0) {
                // Celebration when all tests pass. Gated on totalTests > 0 so an
                // empty run (no input matched, all filtered out) doesn't print the
                // success banner alongside the "No tests were run" warning above.
                console.log(`\n${colors.green}🎉 All items passed! 🎉${colors.reset}`);
            }
        }
        else {
            console.log("No tests were executed or results are in an unknown format.");
        }
        console.log("\n===============================\n");
    },
};
// Helper function to register custom reporters
function registerReporter(name, reporterFunction) {
    if (typeof reporterFunction !== "function") {
        throw new Error("Reporter must be a function");
    }
    reporters[name] = reporterFunction;
    return true;
}
async function reportResults({ apiConfig, results }) {
    // Transform results into the required format for the API
    // Extract contexts from the nested structure and format them
    const contexts = [];
    if (results.specs) {
        results.specs.forEach((spec) => {
            if (spec.tests) {
                spec.tests.forEach((test) => {
                    if (test.contexts) {
                        test.contexts.forEach((context) => {
                            // Extract or generate contextId
                            const contextId = context.contextId;
                            // Convert result status to lowercase (PASS -> passed, FAIL -> failed, etc.)
                            let status;
                            if (context.result === "PASS") {
                                status = "passed";
                            }
                            else if (context.result === "FAIL") {
                                status = "failed";
                            }
                            else if (context.result === "WARNING") {
                                status = "warning";
                            }
                            else if (context.result === "SKIPPED") {
                                status = "skipped";
                            }
                            if (!status) {
                                console.error(`Unknown context result status for context ID ${contextId}`);
                                return;
                            }
                            // Build the context payload with the entire context object embedded
                            contexts.push({
                                contextId: contextId,
                                status: status,
                                result: context,
                            });
                        });
                    }
                });
            }
        });
    }
    // POST to the /contexts endpoint
    try {
        const url = `${apiConfig.url}/contexts`;
        const payload = { contexts };
        console.log(payload);
        const response = await axios.post(url, payload, {
            headers: {
                "x-runner-token": apiConfig.token,
            },
        });
        console.log("Results reported successfully:", response.data);
    }
    catch (error) {
        console.error(`Error reporting results to ${apiConfig.url}/contexts: ${error.message}`);
    }
}
async function outputResults(config = {}, outputPath, results, options = {}) {
    // Config is the source of truth. Fall back to options.reporters for
    // programmatic callers that pass them directly, then to defaults.
    const defaultReporters = ["terminal", "json", "runFolder"];
    let activeReporters = (config && Array.isArray(config.reporters) && config.reporters.length > 0
        ? config.reporters
        : null) ||
        options.reporters ||
        defaultReporters;
    // If the reporters option is provided as strings, normalize them
    if (activeReporters.length > 0) {
        // Convert any shorthand names to full reporter names
        activeReporters = activeReporters.map((reporter) => {
            if (typeof reporter === "string") {
                // Convert shorthand names to actual reporter keys
                switch (reporter.toLowerCase()) {
                    case "json":
                        return "jsonReporter";
                    case "html":
                        return "htmlReporter";
                    case "runfolder":
                        return "runFolderReporter";
                    case "terminal":
                        return "terminalReporter";
                    default:
                        return reporter;
                }
            }
            return reporter;
        });
    }
    // Execute each reporter
    const reporterPromises = activeReporters.map((reporter) => {
        if (typeof reporter === "function") {
            // Direct function reference
            return reporter(config, outputPath, results, options);
        }
        else if (typeof reporter === "string" && reporters[reporter]) {
            // String reference to built-in or registered reporter
            return reporters[reporter](config, outputPath, results, options);
        }
        else if (typeof reporter === "string" && !reporters[reporter]) {
            console.error(`Reporter "${reporter}" not found. Available reporters: ${Object.keys(reporters).join(", ")}`);
            return Promise.resolve();
        }
        else {
            console.error(`Invalid reporter: ${reporter}`);
            return Promise.resolve();
        }
    });
    // Wait for all reporters to complete
    return Promise.all(reporterPromises);
}
// Perform a native command in the current working directory.
async function spawnCommand(cmd, args) {
    // Split command into command and arguments
    if (cmd.includes(" ")) {
        const cmdArray = cmd.split(" ");
        cmd = cmdArray[0];
        const cmdArgs = cmdArray.slice(1);
        // Add arguments to args array
        if (args) {
            args = cmdArgs.concat(args);
        }
        else {
            args = cmdArgs;
        }
    }
    const runCommand = spawn(cmd, args, {
        env: process.env, // Explicitly pass environment variables
    });
    // Capture stdout
    let stdout = "";
    for await (const chunk of runCommand.stdout) {
        stdout += chunk;
    }
    // Remove trailing newline
    stdout = stdout.replace(/\n$/, "");
    // Capture stderr
    let stderr = "";
    for await (const chunk of runCommand.stderr) {
        stderr += chunk;
    }
    // Remove trailing newline
    stderr = stderr.replace(/\n$/, "");
    // Capture exit code
    const exitCode = await new Promise((resolve, reject) => {
        runCommand.on("close", resolve);
    });
    return { stdout, stderr, exitCode };
}
function setMeta() {
    const platformMap = {
        win32: "windows",
        darwin: "mac",
        linux: "linux",
    };
    // Set meta
    const meta = process.env["DOC_DETECTIVE_META"] !== undefined
        ? JSON.parse(process.env["DOC_DETECTIVE_META"])
        : {};
    const pkg = require("../package.json");
    meta.distribution = "doc-detective";
    meta.dist_version = pkg.version;
    meta.dist_platform = platformMap[os.platform()] || os.platform();
    meta.dist_platform_version = os.release();
    meta.dist_platform_arch = os.arch();
    meta.dist_deployment = meta.dist_deployment || "node";
    meta.dist_deployment_version =
        meta.dist_deployment_version || process.version;
    meta.dist_interface = meta.dist_interface || "cli";
    process.env["DOC_DETECTIVE_META"] = JSON.stringify(meta);
}
// Get version data programmatically (no console output)
function getVersionData() {
    try {
        // Get main package version
        const mainPackage = require("../package.json");
        const versionData = {
            main: {
                "doc-detective": {
                    version: mainPackage.version,
                    expected: "main package",
                },
            },
            dependencies: {},
            context: {
                executionMethod: "direct node execution",
                nodeVersion: process.version,
                platform: `${os.platform()} ${os.arch()}`,
                timestamp: new Date().toISOString(),
            },
            locations: {},
        };
        // Auto-discover all doc-detective-* packages in node_modules
        const nodeModulesPath = path.resolve(process.cwd(), "node_modules");
        const dependenciesToCheck = [];
        if (fs.existsSync(nodeModulesPath)) {
            const nodeModulesContents = fs.readdirSync(nodeModulesPath);
            nodeModulesContents.forEach((dir) => {
                if (dir.startsWith("doc-detective-") && dir !== "doc-detective") {
                    dependenciesToCheck.push(dir);
                }
            });
        }
        // Detect execution method
        const isNpx = process.env.npm_execpath && process.env.npm_execpath.includes("npx");
        const isNpm = process.env.npm_execpath && !isNpx;
        if (isNpx) {
            versionData.context.executionMethod = "npx";
        }
        else if (isNpm) {
            versionData.context.executionMethod = "npm";
        }
        // Check installed versions of dependencies
        dependenciesToCheck.sort().forEach((dep) => {
            try {
                // Try to read the dependency's package.json
                const depPackagePath = path.resolve(process.cwd(), "node_modules", dep, "package.json");
                if (fs.existsSync(depPackagePath)) {
                    const depPackage = JSON.parse(fs.readFileSync(depPackagePath, "utf8"));
                    const installedVersion = depPackage.version;
                    // Look for expected version in main package dependencies or devDependencies
                    const expectedVersion = mainPackage.dependencies[dep] ||
                        mainPackage.devDependencies[dep] ||
                        "not specified in main package";
                    versionData.dependencies[dep] = {
                        installed: installedVersion,
                        expected: expectedVersion,
                        status: expectedVersion !== "not specified in main package" &&
                            !expectedVersion.includes(installedVersion) &&
                            !installedVersion.includes(expectedVersion.replace(/[\^~]/, ""))
                            ? "mismatch"
                            : "ok",
                    };
                    versionData.locations[dep] = path.resolve(process.cwd(), "node_modules", dep);
                }
                else {
                    versionData.dependencies[dep] = {
                        installed: null,
                        expected: mainPackage.dependencies[dep] ||
                            mainPackage.devDependencies[dep] ||
                            "not specified",
                        status: "not found",
                        error: "package.json not found",
                    };
                }
            }
            catch (error) {
                versionData.dependencies[dep] = {
                    installed: null,
                    expected: mainPackage.dependencies[dep] ||
                        mainPackage.devDependencies[dep] ||
                        "not specified",
                    status: "error",
                    error: error.message,
                };
            }
        });
        return versionData;
    }
    catch (error) {
        return { error: error.message };
    }
}
//# sourceMappingURL=utils.js.map