//! §Fase 39.f — `axon parse` subcommand (Rust binary parity).
//!
//! Multi-file diagnostic aggregator. Walks the given file paths /
//! directories / globs, runs each `.axon` file through
//! `Parser::parse_with_recovery`, and aggregates every parse error +
//! type-check error into a single report. Mirrors the Python
//! `axon.cli.parse_cmd:cmd_parse` from Fase 28.f.
//!
//! ## Flags
//!
//!  - `--max-errors N` — cap total errors across all files (D6,
//!    default unlimited)
//!  - `--ignore PATTERN` — fnmatch-style ignore pattern (may repeat);
//!    `.axonignore` files in walked dirs are honoured automatically
//!  - `--jobs N` — worker thread count (default: auto). The Rust
//!    implementation currently runs single-threaded; the flag is
//!    accepted for Python-parity but the threading is deferred to a
//!    future fase (honest scope)
//!  - `--json` — emit machine-readable diagnostics (D5)
//!  - `--format array|ndjson` — JSON framing when --json is set
//!  - `--strict` — opt into legacy fail-on-first behavior (D8); also
//!    activated by `AXON_PARSER_STRICT` env var
//!  - `--no-color` — disable ANSI colour codes
//!
//! ## Exit codes (bitwise OR of cause classes)
//!
//!  - `0` — success (no errors)
//!  - `1` — parse / type errors observed
//!  - `2` — I/O errors (file not found, read failed, glob expansion failed)
//!  - `3` — both classes (1 | 2)

use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

use serde::Serialize;

use axon_frontend::lexer::Lexer;
use axon_frontend::parser::Parser;

/// Per-file diagnostic emitted by the aggregator. Wire-stable JSON
/// shape for `--json` mode (rustc-compatible field naming per
/// Fase 28.g D5).
#[derive(Debug, Clone, Serialize)]
pub struct AggregatedDiagnostic {
    pub file: String,
    pub line: u32,
    pub column: u32,
    pub message: String,
    pub kind: String, // "parse" | "lex" | "type"
}

/// Configuration for `axon parse` (mirrors the Python CLI args).
#[derive(Debug, Clone, Default)]
pub struct ParseConfig {
    pub patterns: Vec<String>,
    pub max_errors: Option<usize>,
    pub ignore_patterns: Vec<String>,
    pub jobs: Option<usize>,
    pub json: bool,
    pub format: String, // "array" | "ndjson"
    pub strict: bool,
    pub no_color: bool,
}

/// Run `axon parse` against a configured corpus. Returns a tuple
/// `(diagnostics, io_errors, truncated)`:
///   - `diagnostics`: every parse / lex / type error observed
///   - `io_errors`: files that couldn't be read / glob-expanded
///   - `truncated`: true when `max_errors` capped the report
pub fn run_parse(config: &ParseConfig) -> (Vec<AggregatedDiagnostic>, Vec<String>, bool) {
    let mut diagnostics: Vec<AggregatedDiagnostic> = Vec::new();
    let mut io_errors: Vec<String> = Vec::new();
    let mut truncated = false;

    // ── §1 — Expand patterns into a deterministic file list ──
    let files = match expand_patterns(&config.patterns, &config.ignore_patterns) {
        Ok(f) => f,
        Err(e) => {
            io_errors.push(format!("pattern expansion: {e}"));
            return (diagnostics, io_errors, false);
        }
    };

    // ── §2 — Strict mode: honour env var OR flag (OR semantics) ──
    let strict = config.strict
        || std::env::var("AXON_PARSER_STRICT")
            .ok()
            .map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes" | "on"))
            .unwrap_or(false);

    // ── §3 — Parse each file ──
    'outer: for path in &files {
        // Honour max_errors cap.
        if let Some(cap) = config.max_errors {
            if diagnostics.len() >= cap {
                truncated = true;
                break 'outer;
            }
        }
        let source = match fs::read_to_string(path) {
            Ok(s) => s,
            Err(e) => {
                io_errors.push(format!("read {}: {}", path.display(), e));
                continue;
            }
        };
        let path_str = path.display().to_string();

        // Tokenize
        let tokens = match Lexer::new(&source, &path_str).tokenize() {
            Ok(t) => t,
            Err(e) => {
                diagnostics.push(AggregatedDiagnostic {
                    file: path_str.clone(),
                    line: e.line,
                    column: e.column,
                    message: format!("lex error: {}", e.message),
                    kind: "lex".to_string(),
                });
                if strict {
                    break 'outer;
                }
                continue;
            }
        };

        // Parse with recovery (or fail-fast in strict mode)
        let mut parser = Parser::new(tokens);
        if strict {
            match parser.parse() {
                Ok(_) => {}
                Err(e) => {
                    diagnostics.push(AggregatedDiagnostic {
                        file: path_str.clone(),
                        line: e.line,
                        column: e.column,
                        message: format!("parse error: {}", e.message),
                        kind: "parse".to_string(),
                    });
                    break 'outer; // strict: stop at first failing file
                }
            }
        } else {
            let result = parser.parse_with_recovery();
            for err in result.errors {
                diagnostics.push(AggregatedDiagnostic {
                    file: path_str.clone(),
                    line: err.line,
                    column: err.column,
                    message: format!("parse error: {}", err.message),
                    kind: "parse".to_string(),
                });
                if let Some(cap) = config.max_errors {
                    if diagnostics.len() >= cap {
                        truncated = true;
                        break 'outer;
                    }
                }
            }
        }
    }

    (diagnostics, io_errors, truncated)
}

/// Expand patterns (files / directories / globs) into a
/// deterministic sorted file list. Directories are walked
/// recursively; `.axonignore` files are honoured.
fn expand_patterns(
    patterns: &[String],
    ignore: &[String],
) -> Result<Vec<PathBuf>, String> {
    let mut result: HashSet<PathBuf> = HashSet::new();
    for pattern in patterns {
        let path = PathBuf::from(pattern);
        if path.is_file() {
            if !is_ignored(&path, ignore) {
                result.insert(path);
            }
            continue;
        }
        if path.is_dir() {
            walk_dir(&path, ignore, &mut result)?;
            continue;
        }
        // Not a file or directory — treat as a literal that doesn't
        // resolve. We don't error here; the caller reports it via
        // io_errors when read fails. (Glob expansion is honest
        // scope — Python uses Path.glob; Rust would need an extra
        // crate. For 39.f we accept literal paths + directories
        // and defer glob to a future fase.)
        if path.exists() {
            result.insert(path);
        } else {
            return Err(format!("pattern not found: {pattern}"));
        }
    }
    let mut sorted: Vec<PathBuf> = result.into_iter().collect();
    sorted.sort();
    Ok(sorted)
}

fn walk_dir(
    dir: &Path,
    ignore: &[String],
    out: &mut HashSet<PathBuf>,
) -> Result<(), String> {
    let entries = fs::read_dir(dir).map_err(|e| format!("read_dir {}: {}", dir.display(), e))?;
    for entry in entries {
        let entry = entry.map_err(|e| format!("dir entry: {e}"))?;
        let path = entry.path();
        if is_ignored(&path, ignore) {
            continue;
        }
        if path.is_dir() {
            // Skip common noise dirs.
            let name = path
                .file_name()
                .and_then(|s| s.to_str())
                .unwrap_or("");
            if matches!(name, "target" | "node_modules" | ".git" | "__pycache__") {
                continue;
            }
            walk_dir(&path, ignore, out)?;
        } else if path.extension().and_then(|s| s.to_str()) == Some("axon") {
            out.insert(path);
        }
    }
    Ok(())
}

fn is_ignored(path: &Path, ignore: &[String]) -> bool {
    let path_str = path.to_string_lossy();
    for pattern in ignore {
        // Very simple substring match for v2.0.0; fnmatch parity is
        // a future-fase refinement.
        if path_str.contains(pattern) {
            return true;
        }
    }
    false
}

/// Format diagnostics as a human-readable report for stdout.
pub fn render_human(
    diagnostics: &[AggregatedDiagnostic],
    io_errors: &[String],
    truncated: bool,
    no_color: bool,
) -> String {
    let mut out = String::new();
    let red = if no_color { "" } else { "\x1b[31m" };
    let bold = if no_color { "" } else { "\x1b[1m" };
    let dim = if no_color { "" } else { "\x1b[2m" };
    let reset = if no_color { "" } else { "\x1b[0m" };

    if diagnostics.is_empty() && io_errors.is_empty() {
        out.push_str(&format!("{bold}✓ axon parse: no diagnostics{reset}\n"));
        return out;
    }
    for d in diagnostics {
        out.push_str(&format!(
            "{red}{bold}error{reset}{bold}[{}]{reset} {}\n  {dim}--> {}:{}:{}{reset}\n",
            d.kind, d.message, d.file, d.line, d.column
        ));
    }
    for e in io_errors {
        out.push_str(&format!("{red}{bold}I/O error{reset} {e}\n"));
    }
    if truncated {
        out.push_str(&format!(
            "{dim}... (truncated by --max-errors cap){reset}\n"
        ));
    }
    out
}

/// Format diagnostics as JSON (array or ndjson framing). Rustc-
/// compatible field shape per Fase 28.g D5.
pub fn render_json(
    diagnostics: &[AggregatedDiagnostic],
    format: &str,
) -> String {
    if format == "ndjson" {
        diagnostics
            .iter()
            .map(|d| serde_json::to_string(d).unwrap_or_default())
            .collect::<Vec<_>>()
            .join("\n")
            + "\n"
    } else {
        serde_json::to_string_pretty(diagnostics).unwrap_or_default() + "\n"
    }
}

/// Compute the exit code from the diagnostics + io_errors observed.
/// Mirrors the Python CLI's bitwise OR convention (D6).
pub fn exit_code(
    diagnostics: &[AggregatedDiagnostic],
    io_errors: &[String],
) -> i32 {
    let mut code = 0;
    if !diagnostics.is_empty() {
        code |= 1;
    }
    if !io_errors.is_empty() {
        code |= 2;
    }
    code
}

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

    #[test]
    fn fase39f_parse_empty_patterns_returns_clean() {
        let cfg = ParseConfig::default();
        let (diags, ios, trunc) = run_parse(&cfg);
        assert!(diags.is_empty());
        assert!(ios.is_empty());
        assert!(!trunc);
    }

    #[test]
    fn fase39f_exit_code_zero_on_clean() {
        assert_eq!(exit_code(&[], &[]), 0);
    }

    #[test]
    fn fase39f_exit_code_one_on_diagnostic() {
        let d = AggregatedDiagnostic {
            file: "x.axon".to_string(),
            line: 1,
            column: 1,
            message: "boom".to_string(),
            kind: "parse".to_string(),
        };
        assert_eq!(exit_code(&[d], &[]), 1);
    }

    #[test]
    fn fase39f_exit_code_two_on_io_error() {
        assert_eq!(exit_code(&[], &["read failed".to_string()]), 2);
    }

    #[test]
    fn fase39f_exit_code_three_on_both() {
        let d = AggregatedDiagnostic {
            file: "x.axon".to_string(),
            line: 1,
            column: 1,
            message: "boom".to_string(),
            kind: "parse".to_string(),
        };
        assert_eq!(exit_code(&[d], &["io".to_string()]), 3);
    }

    #[test]
    fn fase39f_json_array_format_serializes_diagnostics() {
        let d = AggregatedDiagnostic {
            file: "x.axon".to_string(),
            line: 1,
            column: 1,
            message: "boom".to_string(),
            kind: "parse".to_string(),
        };
        let out = render_json(&[d], "array");
        assert!(out.contains("\"file\": \"x.axon\""));
        assert!(out.contains("\"kind\": \"parse\""));
    }

    #[test]
    fn fase39f_json_ndjson_format_one_per_line() {
        let d1 = AggregatedDiagnostic {
            file: "a.axon".to_string(),
            line: 1,
            column: 1,
            message: "e1".to_string(),
            kind: "parse".to_string(),
        };
        let d2 = AggregatedDiagnostic {
            file: "b.axon".to_string(),
            line: 2,
            column: 2,
            message: "e2".to_string(),
            kind: "parse".to_string(),
        };
        let out = render_json(&[d1, d2], "ndjson");
        let lines: Vec<&str> = out.trim().split('\n').collect();
        assert_eq!(lines.len(), 2);
        assert!(lines[0].contains("a.axon"));
        assert!(lines[1].contains("b.axon"));
    }

    #[test]
    fn fase39f_human_render_clean_emits_check() {
        let out = render_human(&[], &[], false, true);
        assert!(out.contains("axon parse: no diagnostics"));
    }

    #[test]
    fn fase39f_human_render_truncated_marker() {
        let d = AggregatedDiagnostic {
            file: "x".to_string(),
            line: 1,
            column: 1,
            message: "e".to_string(),
            kind: "parse".to_string(),
        };
        let out = render_human(&[d], &[], true, true);
        assert!(out.contains("truncated by --max-errors"));
    }

    #[test]
    fn fase39f_strict_env_var_recognized() {
        // Verify the AXON_PARSER_STRICT env var truthy alphabet
        // matches the Fase 28.h Python contract.
        for truthy in &["1", "true", "yes", "on", "TRUE", "Yes"] {
            std::env::set_var("AXON_PARSER_STRICT", truthy);
            let cfg = ParseConfig::default();
            let _ = run_parse(&cfg); // doesn't panic
        }
        std::env::remove_var("AXON_PARSER_STRICT");
    }
}
