//! Shared implementation for bean-check and rledger check commands.

use crate::cmd::completions::ShellType;
use crate::report::{self, SourceCache};
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use rustledger_core::Directive;
use rustledger_loader::LoadError;
#[cfg(feature = "python-plugin-wasm")]
use rustledger_plugin::PluginManager;
#[cfg(feature = "python-plugin-wasm")]
use rustledger_plugin::{PluginInput, PluginOptions};
use serde::Serialize;
use std::io::{self, Write};
use std::path::PathBuf;
use std::process::ExitCode;

/// Output format for diagnostics.
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub enum OutputFormat {
    /// Human-readable text output (default)
    #[default]
    Text,
    /// JSON output for IDE/tooling integration
    Json,
}

/// Advisory lints that can be run alongside `check`.
///
/// Modeled as an enum (not a free-form `String`) so unknown names like
/// `--lint tranfsers` fail at argument parsing time instead of silently
/// no-op'ing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum LintName {
    /// Detect likely unlinked inter-account transfer pairs.
    Transfers,
}

/// A diagnostic message in JSON format.
#[derive(Debug, Serialize)]
pub struct JsonDiagnostic {
    /// Source file path
    pub file: String,
    /// Line number (1-based)
    pub line: usize,
    /// Column number (1-based)
    pub column: usize,
    /// End line number (1-based)
    pub end_line: usize,
    /// End column number (1-based)
    pub end_column: usize,
    /// Severity: "error" or "warning"
    pub severity: String,
    /// Processing phase: "parse", "validate", or "plugin"
    pub phase: String,
    /// Error code (e.g., "P0012", "E1001")
    pub code: String,
    /// Error message
    pub message: String,
    /// Optional hint for fixing the error
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hint: Option<String>,
    /// Optional context information
    #[serde(skip_serializing_if = "Option::is_none")]
    pub context: Option<String>,
}

/// JSON output structure for all diagnostics.
#[derive(Debug, Serialize)]
pub struct JsonOutput {
    /// List of diagnostics
    pub diagnostics: Vec<JsonDiagnostic>,
    /// Total error count
    pub error_count: usize,
    /// Total warning count
    pub warning_count: usize,
    /// Number of parse-phase errors
    pub parse_error_count: usize,
    /// Number of validate-phase errors
    pub validate_error_count: usize,
}

/// Convert a byte offset to (line, column) in 1-based indexing.
fn byte_offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
    let mut line = 1;
    let mut col = 1;
    for (i, ch) in source.char_indices() {
        if i >= offset {
            break;
        }
        if ch == '\n' {
            line += 1;
            col = 1;
        } else {
            col += 1;
        }
    }
    (line, col)
}

/// Validate beancount files and report errors.
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct Args {
    /// The beancount file to check (uses config default if not specified)
    #[arg(value_name = "FILE")]
    pub file: Option<PathBuf>,

    /// Generate shell completions and exit
    #[arg(long, value_name = "SHELL", hide = true)]
    pub generate_completions: Option<ShellType>,

    /// Show verbose output including timing information
    #[arg(short, long)]
    pub verbose: bool,

    /// Suppress all output (just use exit code)
    #[arg(short, long)]
    pub quiet: bool,

    /// Disable the binary cache for parsed directives.
    ///
    /// Also honored: the `BEANCOUNT_DISABLE_LOAD_CACHE` environment variable
    /// (matching Python beancount). Set the `BEANCOUNT_LOAD_CACHE_FILENAME`
    /// env var to redirect the cache to a custom path.
    #[arg(short = 'C', long = "no-cache")]
    pub no_cache: bool,

    /// Override the cache filename (not yet implemented)
    #[arg(long, value_name = "CACHE_FILE", hide = true)]
    pub cache_filename: Option<PathBuf>,

    /// Implicitly enable auto-plugins (`auto_accounts`, etc.)
    #[arg(short = 'a', long)]
    pub auto: bool,

    /// Load a WASM plugin (can be specified multiple times)
    #[cfg(feature = "python-plugin-wasm")]
    #[arg(long = "plugin", value_name = "WASM_FILE")]
    pub plugins: Vec<PathBuf>,

    /// Run built-in native plugins (e.g., `implicit_prices`, `check_commodity`)
    #[arg(long = "native-plugin", value_name = "NAME")]
    pub native_plugins: Vec<String>,

    /// Output format (text or json)
    #[arg(long, short = 'f', value_enum, default_value = "text")]
    pub format: OutputFormat,

    /// Run non-fatal advisory lints alongside validation.
    ///
    /// Repeatable to enable multiple lints. Findings are emitted as
    /// warnings, never errors — exit code is unaffected.
    #[arg(long = "lint", value_enum, value_name = "NAME")]
    pub lints: Vec<LintName>,

    /// Minimum confidence (0.0 - 1.0) for `--lint transfers` matches to be
    /// reported. Default 0.8 silences the noisy 0.7 floor.
    #[arg(long, default_value_t = 0.8)]
    pub lint_min_confidence: f64,
}

/// Run the check command, writing all output to stdout.
///
/// Thin wrapper over [`run_with_writer`] for the synchronous `rledger`
/// binary. The agent-native `ag-rledger` binary calls `run_with_writer`
/// directly with a buffer so the diagnostics can be captured into a JSON
/// envelope.
pub fn run(args: &Args) -> Result<ExitCode> {
    let mut stdout = io::stdout().lock();
    run_with_writer(args, &mut stdout)
}

/// Run the check command with the given arguments, writing diagnostics to
/// `stdout`.
///
/// Behavior is identical to the original `run()`; the only change is that
/// human-readable and JSON output go to the injected writer instead of a
/// hard-coded `io::stdout().lock()`. This lets `ag-rledger` buffer the
/// output into an agent envelope without spawning a subprocess.
pub fn run_with_writer<W: Write>(args: &Args, stdout: &mut W) -> Result<ExitCode> {
    let start = std::time::Instant::now();

    // File is required (the --generate-completions flag is only for standalone bean-check)
    let Some(file) = args.file.as_ref() else {
        anyhow::bail!("FILE is required");
    };

    // Check if file exists
    if !file.exists() {
        anyhow::bail!("file not found: {}", file.display());
    }

    // Collect diagnostics for JSON output
    let json_mode = matches!(args.format, OutputFormat::Json);
    let mut diagnostics: Vec<JsonDiagnostic> = Vec::new();

    // Determine if colors should be used (TTY detection + NO_COLOR)
    let use_color = !json_mode && report::should_use_color();

    // Load the parsed file via the shared on-disk parse cache
    // (`cmd::loadcache::load_result_cached`): a cache hit skips the
    // expensive parse and reconstructs an equivalent `LoadResult`,
    // otherwise it parses and saves. `--no-cache` /
    // `BEANCOUNT_DISABLE_LOAD_CACHE` disable it. `from_cache` drives the
    // "(from cache)" note below.
    let (load_result, from_cache) = crate::cmd::loadcache::load_result_cached(
        file,
        args.no_cache,
        args.verbose && !args.quiet,
    )?;

    // Build source cache for error reporting
    let mut cache = SourceCache::new();
    for source_file in load_result.source_map.files() {
        // Use lossy UTF-8 decoding to handle non-UTF-8 files gracefully
        let content = std::fs::read(&source_file.path)
            .map(|b| String::from_utf8_lossy(&b).into_owned())
            .unwrap_or_default();
        let path_str = source_file.path.display().to_string();
        cache.add(&path_str, content);
    }

    // Also add the main file (use lossy decoding for non-UTF-8 files)
    let main_content = std::fs::read(file)
        .map(|b| String::from_utf8_lossy(&b).into_owned())
        .with_context(|| format!("failed to read {}", file.display()))?;
    cache.add(&file.display().to_string(), main_content);

    // Count errors split by phase
    let mut error_count = 0;
    let mut parse_error_count = 0;
    let mut validate_error_count = 0;

    // Report load/parse errors
    for load_error in &load_result.errors {
        match load_error {
            LoadError::ParseErrors { path, errors } => {
                let source = std::fs::read_to_string(path).unwrap_or_default();
                let path_str = path.display().to_string();

                if json_mode {
                    for error in errors {
                        let (start_line, start_col) =
                            byte_offset_to_line_col(&source, error.span.start);
                        let (end_line, end_col) = byte_offset_to_line_col(&source, error.span.end);
                        diagnostics.push(JsonDiagnostic {
                            file: path_str.clone(),
                            line: start_line,
                            column: start_col,
                            end_line,
                            end_column: end_col,
                            severity: "error".to_string(),
                            phase: "parse".to_string(),
                            code: format!("P{:04}", error.kind_code()),
                            message: error.message(),
                            hint: error.hint.clone(),
                            context: error.context.clone(),
                        });
                    }
                    error_count += errors.len();
                    parse_error_count += errors.len();
                } else if args.quiet {
                    error_count += errors.len();
                } else {
                    error_count +=
                        report::report_parse_errors(errors, path, &source, stdout, use_color)?;
                }
            }
            LoadError::Io { path, source } => {
                let path_str = path.display().to_string();
                if json_mode {
                    diagnostics.push(JsonDiagnostic {
                        file: path_str,
                        line: 1,
                        column: 1,
                        end_line: 1,
                        end_column: 1,
                        severity: "error".to_string(),
                        phase: "parse".to_string(),
                        code: "E0001".to_string(),
                        message: format!("failed to read file: {source}"),
                        hint: None,
                        context: None,
                    });
                    parse_error_count += 1;
                } else if !args.quiet {
                    writeln!(stdout, "error: failed to read {path_str}: {source}")?;
                }
                error_count += 1;
            }
            LoadError::IncludeCycle { cycle } => {
                // Delegate to the canonical Display impl on
                // `LoadError::IncludeCycle` so the wording lives in
                // exactly one place (the `#[error(...)]` attribute on
                // the variant). This is load-bearing for pta-standards
                // conformance (#765): the substring `"Duplicate
                // filename"` must appear, and centralizing the format
                // string prevents it from drifting out of sync with the
                // library-level error.
                let message = load_error.to_string();
                if json_mode {
                    diagnostics.push(JsonDiagnostic {
                        file: cycle.first().cloned().unwrap_or_default(),
                        line: 1,
                        column: 1,
                        end_line: 1,
                        end_column: 1,
                        severity: "error".to_string(),
                        phase: "parse".to_string(),
                        code: "E0002".to_string(),
                        message,
                        hint: Some("break the cycle by removing one of the includes".to_string()),
                        context: None,
                    });
                    parse_error_count += 1;
                } else if !args.quiet {
                    writeln!(stdout, "error: {message}")?;
                }
                error_count += 1;
            }
            LoadError::PathTraversal {
                include_path,
                base_dir,
            } => {
                if json_mode {
                    diagnostics.push(JsonDiagnostic {
                        file: base_dir.display().to_string(),
                        line: 1,
                        column: 1,
                        end_line: 1,
                        end_column: 1,
                        severity: "error".to_string(),
                        phase: "parse".to_string(),
                        code: "E0003".to_string(),
                        message: format!(
                            "path traversal not allowed: {} escapes {}",
                            include_path,
                            base_dir.display()
                        ),
                        hint: Some("use paths within the base directory".to_string()),
                        context: None,
                    });
                    parse_error_count += 1;
                } else if !args.quiet {
                    writeln!(
                        stdout,
                        "error: path traversal not allowed: {} escapes {}",
                        include_path,
                        base_dir.display()
                    )?;
                }
                error_count += 1;
            }
            LoadError::Decryption { path, message } => {
                let path_str = path.display().to_string();
                if json_mode {
                    diagnostics.push(JsonDiagnostic {
                        file: path_str,
                        line: 1,
                        column: 1,
                        end_line: 1,
                        end_column: 1,
                        severity: "error".to_string(),
                        phase: "parse".to_string(),
                        code: "E0004".to_string(),
                        message: format!("failed to decrypt: {message}"),
                        hint: None,
                        context: None,
                    });
                    parse_error_count += 1;
                } else if !args.quiet {
                    writeln!(
                        stdout,
                        "error: failed to decrypt {}: {}",
                        path.display(),
                        message
                    )?;
                }
                error_count += 1;
            }
            LoadError::GlobNoMatch { pattern } => {
                if json_mode {
                    diagnostics.push(JsonDiagnostic {
                        file: file.display().to_string(),
                        line: 1,
                        column: 1,
                        end_line: 1,
                        end_column: 1,
                        severity: "error".to_string(),
                        phase: "parse".to_string(),
                        code: "E0005".to_string(),
                        message: format!("include pattern \"{pattern}\" does not match any files"),
                        hint: Some(
                            "check that the glob pattern is correct and files exist".to_string(),
                        ),
                        context: None,
                    });
                    parse_error_count += 1;
                } else if !args.quiet {
                    writeln!(
                        stdout,
                        "error: include pattern \"{pattern}\" does not match any files"
                    )?;
                }
                error_count += 1;
            }
            LoadError::GlobError { pattern, message } => {
                if json_mode {
                    diagnostics.push(JsonDiagnostic {
                        file: file.display().to_string(),
                        line: 1,
                        column: 1,
                        end_line: 1,
                        end_column: 1,
                        severity: "error".to_string(),
                        phase: "parse".to_string(),
                        code: "E0006".to_string(),
                        message: format!(
                            "failed to expand include pattern \"{pattern}\": {message}"
                        ),
                        hint: None,
                        context: None,
                    });
                    parse_error_count += 1;
                } else if !args.quiet {
                    writeln!(
                        stdout,
                        "error: failed to expand include pattern \"{pattern}\": {message}"
                    )?;
                }
                error_count += 1;
            }
        }
    }

    // Report option errors (E7001, E7002, E7003)
    // In Python beancount, invalid options are errors, not warnings
    let main_file_str = file.display().to_string();
    let option_error_count = load_result.options.warnings.len();
    for warning in &load_result.options.warnings {
        if json_mode {
            diagnostics.push(JsonDiagnostic {
                file: main_file_str.clone(),
                line: 1,
                column: 1,
                end_line: 1,
                end_column: 1,
                severity: "error".to_string(),
                phase: "parse".to_string(),
                code: warning.code.to_string(),
                message: warning.message.clone(),
                hint: None,
                context: None,
            });
            parse_error_count += 1;
        } else if !args.quiet {
            writeln!(stdout, "error[{}]: {}", warning.code, warning.message)?;
        }
    }
    error_count += option_error_count;

    // === Delegate booking, plugins, and validation to process::process() ===
    //
    // process::process() is the single source of truth for the core pipeline:
    // sort → synth-plugins → Early validation → book → regular-plugins (native +
    // WASM + Python) → Late validation → finalize.
    // check.rs handles: caching, load error reporting, JSON formatting,
    // and CLI-specified --plugin WASM files (below).

    // Build LoadOptions for the processing pipeline
    let load_options = rustledger_loader::LoadOptions {
        run_plugins: true,
        auto_accounts: args.auto,
        extra_plugins: args
            .native_plugins
            .iter()
            .map(|name| rustledger_loader::ExtraPlugin {
                name: name.clone(),
                config: None,
            })
            .collect(),
        validate: true,
        ..Default::default()
    };

    // Clear load errors from the result (already reported above with rich formatting)
    let mut process_input = load_result;
    process_input.errors.clear();

    let ledger = rustledger_loader::process(process_input, &load_options)
        .with_context(|| "processing pipeline failed")?;

    // Normalize total prices (@@→@) AFTER validation to preserve exact totals for
    // precise residual calculation.
    let mut spanned_directives = ledger.directives;
    for spanned in &mut spanned_directives {
        if let Directive::Transaction(txn) = &mut spanned.value {
            rustledger_booking::normalize_prices(txn);
        }
    }

    let source_map = &ledger.source_map;
    // One renderer per invocation: amortizes GraphicalReportHandler setup
    // and caches NamedSource per file_id across all errors.
    let mut ledger_error_renderer = report::LedgerErrorRenderer::new(use_color);

    // Convert process errors to diagnostics, using the phase field to
    // split into parse/validate/plugin categories.
    for err in &ledger.errors {
        let severity_str = match err.severity {
            rustledger_loader::ErrorSeverity::Error => "error",
            rustledger_loader::ErrorSeverity::Warning => "warning",
        };

        if json_mode {
            // Compute end line/column from the error's byte span when
            // available, so multi-line directives (e.g. an unbalanced
            // transaction covering 3 lines) report a real end position
            // instead of falling back to start==end (issue #901).
            let loc = err.location.as_ref();
            let fallback_end = (loc.map_or(1, |l| l.line), loc.map_or(1, |l| l.column));
            let (end_line, end_column) = err
                .source_span
                .zip(err.file_id)
                .and_then(|((_, end), fid)| source_map.get(fid as usize).map(|f| f.line_col(end)))
                .unwrap_or(fallback_end);
            diagnostics.push(JsonDiagnostic {
                file: err
                    .location
                    .as_ref()
                    .map_or_else(|| main_file_str.clone(), |l| l.file.display().to_string()),
                line: err.location.as_ref().map_or(1, |l| l.line),
                column: err.location.as_ref().map_or(1, |l| l.column),
                end_line,
                end_column,
                severity: severity_str.to_string(),
                phase: err.phase.clone(),
                code: err.code.clone(),
                message: err.message.clone(),
                hint: None,
                context: None,
            });

            match (err.severity, err.phase.as_str()) {
                (rustledger_loader::ErrorSeverity::Error, "parse") => {
                    parse_error_count += 1;
                }
                (rustledger_loader::ErrorSeverity::Error, "validate") => {
                    validate_error_count += 1;
                }
                _ => {}
            }
        } else if !args.quiet {
            // When the error carries span+file_id and we can resolve the
            // source, render via miette so the user gets a snippet of the
            // offending directive (issue #901). Fall back to a one-line
            // `file:line:col: error[CODE]: message` for errors without
            // span info (e.g. plugin errors, cross-file invariants).
            ledger_error_renderer.render(err, source_map, stdout)?;
        }

        if matches!(err.severity, rustledger_loader::ErrorSeverity::Error) {
            error_count += 1;
        }
    }
    let warning_count = ledger
        .errors
        .iter()
        .filter(|e| matches!(e.severity, rustledger_loader::ErrorSeverity::Warning))
        .count();
    #[cfg(feature = "python-plugin-wasm")]
    let mut warning_count = warning_count;

    // === Run CLI-specified WASM plugins as post-processing ===
    // File-declared plugins (native, WASM, Python) are all handled by
    // process::process(). Only CLI --plugin flags need post-process handling.
    #[cfg(feature = "python-plugin-wasm")]
    if !args.plugins.is_empty() {
        let wrappers: Vec<_> = spanned_directives
            .iter()
            .map(|s| rustledger_plugin::directive_to_wrapper(&s.value))
            .collect();

        let current_input = PluginInput {
            directives: wrappers,
            options: PluginOptions {
                operating_currencies: ledger.options.operating_currency.clone(),
                title: ledger.options.title.clone(),
            },
            config: None,
        };

        let mut wasm_mgr = PluginManager::new();
        for plugin_path in &args.plugins {
            if let Err(e) = wasm_mgr.load(plugin_path) {
                let msg = format!("failed to load WASM plugin {}: {e}", plugin_path.display());
                if json_mode {
                    diagnostics.push(JsonDiagnostic {
                        file: main_file_str.clone(),
                        line: 1,
                        column: 1,
                        end_line: 1,
                        end_column: 1,
                        severity: "error".to_string(),
                        phase: "plugin".to_string(),
                        code: "PLUGIN".to_string(),
                        message: msg,
                        hint: None,
                        context: None,
                    });
                } else if !args.quiet {
                    writeln!(stdout, "error: {msg}")?;
                }
                error_count += 1;
            }
        }
        if !wasm_mgr.is_empty() {
            match wasm_mgr.execute_all(current_input) {
                Ok(output) => {
                    for err in &output.errors {
                        let sev = match err.severity {
                            rustledger_plugin::PluginErrorSeverity::Error => "error",
                            rustledger_plugin::PluginErrorSeverity::Warning => "warning",
                        };
                        if json_mode {
                            diagnostics.push(JsonDiagnostic {
                                file: main_file_str.clone(),
                                line: 1,
                                column: 1,
                                end_line: 1,
                                end_column: 1,
                                severity: sev.to_string(),
                                phase: "plugin".to_string(),
                                code: "PLUGIN".to_string(),
                                message: err.message.clone(),
                                hint: None,
                                context: None,
                            });
                        } else if !args.quiet {
                            writeln!(stdout, "{sev}: {}", err.message)?;
                        }
                        match err.severity {
                            rustledger_plugin::PluginErrorSeverity::Error => {
                                error_count += 1;
                            }
                            rustledger_plugin::PluginErrorSeverity::Warning => {
                                warning_count += 1;
                            }
                        }
                    }
                }
                Err(e) => {
                    let msg = format!("WASM plugin execution failed: {e}");
                    if json_mode {
                        diagnostics.push(JsonDiagnostic {
                            file: main_file_str.clone(),
                            line: 1,
                            column: 1,
                            end_line: 1,
                            end_column: 1,
                            severity: "error".to_string(),
                            phase: "plugin".to_string(),
                            code: "PLUGIN".to_string(),
                            message: msg,
                            hint: None,
                            context: None,
                        });
                    } else if !args.quiet {
                        writeln!(stdout, "error: {msg}")?;
                    }
                    error_count += 1;
                }
            }
        }
    }

    // === Non-fatal advisory lints (--lint NAME) ===
    // Lint findings are warnings, never errors. They never affect exit code.
    // Under `python-plugin-wasm` the binding above is already `mut`; rebind
    // here only for the other cfg branch.
    #[cfg(not(feature = "python-plugin-wasm"))]
    let mut warning_count = warning_count;
    if args.lints.contains(&LintName::Transfers) {
        let mut wrappers: Vec<rustledger_plugin::types::DirectiveWrapper> =
            Vec::with_capacity(spanned_directives.len());
        for spanned in &spanned_directives {
            let (filename, lineno) = if let Some(file) = source_map.get(spanned.file_id as usize) {
                let (line, _col) = file.line_col(spanned.span.start);
                (
                    Some(file.path.to_string_lossy().into_owned()),
                    u32::try_from(line).ok(),
                )
            } else {
                (None, None)
            };
            wrappers.push(rustledger_plugin::directive_to_wrapper_with_location(
                &spanned.value,
                filename,
                lineno,
            ));
        }
        let config = rustledger_ops::transfer::TransferConfig::default();
        let matches: Vec<_> =
            rustledger_ops::transfer::find_transfers_in_ledger(&wrappers, &config)
                .into_iter()
                .filter(|m| m.confidence >= args.lint_min_confidence)
                .collect();
        for m in &matches {
            let msg = format!(
                "likely transfer pair: {} {} {} → {} (confidence {:.2}); link with ^xfer-... to silence",
                m.amount,
                m.currency,
                m.from_account.as_deref().unwrap_or("?"),
                m.to_account.as_deref().unwrap_or("?"),
                m.confidence,
            );
            if json_mode {
                diagnostics.push(JsonDiagnostic {
                    file: m
                        .from_filename
                        .clone()
                        .unwrap_or_else(|| main_file_str.clone()),
                    line: m.from_lineno.map_or(1, |n| n as usize),
                    column: 1,
                    end_line: m.from_lineno.map_or(1, |n| n as usize),
                    end_column: 1,
                    severity: "warning".to_string(),
                    phase: "lint".to_string(),
                    code: "LINT-XFER".to_string(),
                    message: msg,
                    hint: Some(
                        "run `rledger lint transfers --apply <files>` to add links".to_string(),
                    ),
                    context: None,
                });
            } else if !args.quiet {
                let loc = format!(
                    "{}:{}",
                    m.from_filename.as_deref().unwrap_or("?"),
                    m.from_lineno.map_or_else(|| "?".into(), |n| n.to_string()),
                );
                writeln!(stdout, "{loc}: warning[LINT-XFER]: {msg}")?;
            }
            warning_count += 1;
        }
    }

    // Print summary / output
    let elapsed = start.elapsed();

    if json_mode {
        let output = JsonOutput {
            diagnostics,
            error_count,
            warning_count,
            parse_error_count,
            validate_error_count,
        };
        writeln!(stdout, "{}", serde_json::to_string_pretty(&output)?)?;
    } else if !args.quiet {
        if args.verbose {
            let cache_note = if from_cache { " (from cache)" } else { "" };
            writeln!(
                stdout,
                "\nChecked in {:.2}ms{}",
                elapsed.as_secs_f64() * 1000.0,
                cache_note
            )?;
        }
        report::print_summary(error_count, warning_count, stdout, use_color)?;
    }

    if error_count > 0 {
        Ok(ExitCode::from(1))
    } else {
        Ok(ExitCode::SUCCESS)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_json_diagnostic_phase_field_serializes() {
        let diag = JsonDiagnostic {
            file: "test.beancount".to_string(),
            line: 1,
            column: 1,
            end_line: 1,
            end_column: 1,
            severity: "error".to_string(),
            phase: "parse".to_string(),
            code: "P0001".to_string(),
            message: "test error".to_string(),
            hint: None,
            context: None,
        };
        let json = serde_json::to_value(&diag).unwrap();
        assert_eq!(json["phase"], "parse");

        let diag_validate = JsonDiagnostic {
            phase: "validate".to_string(),
            ..diag
        };
        let json = serde_json::to_value(&diag_validate).unwrap();
        assert_eq!(json["phase"], "validate");
    }

    #[test]
    fn test_json_output_includes_phase_counts() {
        let output = JsonOutput {
            diagnostics: vec![],
            error_count: 3,
            warning_count: 0,
            parse_error_count: 1,
            validate_error_count: 2,
        };
        let json = serde_json::to_value(&output).unwrap();
        assert_eq!(json["parse_error_count"], 1);
        assert_eq!(json["validate_error_count"], 2);
        assert_eq!(json["error_count"], 3);
    }
}
