use anyhow::{Context, Result};
use clap::Parser;
use std::collections::BTreeMap;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use toggle::cli::Cli;
use toggle::config::ToggleConfig;
use toggle::core;
use toggle::exit_codes::{ExitCode, UsageError};
use toggle::io;
use toggle::journal;
use toggle::walk;

/// Bundled options passed through the toggle pipeline.
struct ToggleOptions<'a> {
    force: &'a Option<String>,
    mode: &'a str,
    temp_suffix: Option<&'a str>,
    dry_run: bool,
    backup: Option<&'a str>,
    config: Option<&'a ToggleConfig>,
    verbose: bool,
    eol: &'a str,
    no_dereference: bool,
    encoding: &'a str,
    json: bool,
    to_end: bool,
    comment_style_override: &'a [String],
    interactive: bool,
}

/// Result of processing a single toggle operation.
struct ProcessResult {
    action: String,
    lines_changed: usize,
    section_id: Option<String>,
    desc: Option<String>,
}

/// JSON output entry for --json mode.
#[derive(serde::Serialize)]
struct ToggleResult {
    file: String,
    action: String,
    lines_changed: usize,
    success: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    error: Option<String>,
    dry_run: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    section_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    desc: Option<String>,
}

/// JSON output for --list-sections mode.
#[derive(serde::Serialize)]
struct SectionListEntry {
    id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    desc: Option<String>,
    files: Vec<SectionFileEntry>,
}

/// A single file location in --list-sections JSON output.
#[derive(serde::Serialize)]
struct SectionFileEntry {
    file: String,
    start_line: usize,
    end_line: usize,
}

fn main() {
    let cli = match Cli::try_parse() {
        Ok(cli) => cli,
        Err(e) => {
            // Let clap print its error/help message
            let _ = e.print();
            // Use our custom Usage exit code instead of clap's default (2)
            std::process::exit(ExitCode::Usage.code());
        }
    };

    let result = run(&cli);
    let code = match &result {
        Ok(_) => ExitCode::Success,
        Err(e) => classify_error(e),
    };

    if let Err(e) = &result {
        if !cli.json {
            eprintln!("Error: {:#}", e);
        }
    }

    let exit_val = if cli.posix_exit {
        code.posix()
    } else {
        code.code()
    };
    std::process::exit(exit_val);
}

fn classify_error(err: &anyhow::Error) -> ExitCode {
    // Walk the error chain looking for specific typed errors
    for cause in err.chain() {
        if cause.downcast_ref::<std::io::Error>().is_some() {
            return ExitCode::IoError;
        }
        if cause.downcast_ref::<UsageError>().is_some() {
            return ExitCode::Usage;
        }
    }

    ExitCode::ToggleError
}

fn run(cli: &Cli) -> Result<()> {
    // ── Meta short-circuits: completions and man page ──
    if let Some(shell) = cli.completions {
        let mut command = <Cli as clap::CommandFactory>::command();
        let bin = command.get_name().to_string();
        clap_complete::generate(shell, &mut command, bin, &mut std::io::stdout());
        return Ok(());
    }
    if cli.man {
        let command = <Cli as clap::CommandFactory>::command();
        clap_mangen::Man::new(command)
            .render(&mut std::io::stdout())
            .context("failed to render man page")?;
        return Ok(());
    }

    // Path is required for everything else
    if cli.paths.is_empty() {
        return Err(UsageError("at least one file or directory path is required".into()).into());
    }

    // ── Atomic mode: startup journal check ──
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    let journal_path = cwd.join(journal::JOURNAL_FILENAME);

    // Handle --recover flag
    if cli.recover {
        journal::perform_recovery(&journal_path, cli.recover_forward)
            .map_err(|e| anyhow::anyhow!("{}", e))?;
        return Ok(());
    }

    // Check for leftover journal from a previous interrupted run
    if journal_path.exists() && !cli.recover {
        return Err(UsageError(
            "A previous atomic operation was interrupted. \
             Run with --recover to clean up, or --recover --recover-forward to complete it."
                .into(),
        )
        .into());
    }

    // ── Atomic mode: CLI validation ──
    if cli.atomic && cli.dry_run {
        return Err(UsageError("--atomic cannot be combined with --dry-run.".into()).into());
    }
    if cli.no_backup && !cli.atomic {
        return Err(UsageError("--no-backup is only valid with --atomic.".into()).into());
    }
    if cli.recover_forward && !cli.recover {
        return Err(UsageError("--recover-forward requires --recover.".into()).into());
    }
    // Note: --atomic --stdout is not applicable (no --stdout flag exists yet)
    // Note: --atomic --in-place is not applicable (no --in-place flag exists yet)

    let config = if let Some(config_path) = &cli.config {
        Some(ToggleConfig::load(config_path)?)
    } else {
        None
    };

    // CLI flags override config values
    let effective_mode = if cli.mode == "auto" {
        config
            .as_ref()
            .and_then(|c| c.global.as_ref())
            .and_then(|g| g.default_mode.as_deref())
            .unwrap_or("auto")
            .to_string()
    } else {
        cli.mode.clone()
    };

    let effective_force = if let Some(ref val) = cli.force {
        match val.as_str() {
            "on" | "off" => cli.force.clone(),
            "invert" => None,
            other => {
                return Err(UsageError(format!(
                    "Invalid --force value '{}': expected on, off, or invert",
                    other
                ))
                .into());
            }
        }
    } else {
        config
            .as_ref()
            .and_then(|c| c.global.as_ref())
            .and_then(|g| g.force_state.as_deref())
            .filter(|&s| s != "none")
            .map(String::from)
    };

    // Validate --encoding value (before --scan so it applies to all modes)
    if !io::is_valid_encoding(&cli.encoding) {
        return Err(UsageError(format!("Unsupported encoding: '{}'", cli.encoding)).into());
    }

    // Handle --scan mode early (read-only, no toggle options needed).
    // Per PRD §0.14.2, --scan -S <id> is the detailed group view, so --section is allowed here.
    if cli.scan {
        if !cli.lines.is_empty() {
            return Err(UsageError("--scan cannot be combined with --line".into()).into());
        }
        if cli.force.is_some() {
            return Err(UsageError("--scan cannot be combined with --force".into()).into());
        }
        return run_scan(cli);
    }

    if cli.check && !cli.scan {
        return Err(UsageError("--check requires --scan".into()).into());
    }

    // Validate --comment-style: must be 1 or 3 values
    if cli.comment_style.len() == 2 {
        return Err(UsageError(
            "--comment-style requires 1 value (single-line) or 3 values (single-line, multi-start, multi-end)".into(),
        )
        .into());
    }

    // Validate --to-end requires --line
    if cli.to_end && cli.lines.is_empty() {
        return Err(UsageError("--to-end requires at least one --line range".into()).into());
    }

    // Validate --pair: pre-execution guard per PRD §0.13.4
    if cli.pair {
        if cli.sections.is_empty() {
            return Err(UsageError("--pair requires at least one -S <group>".into()).into());
        }
        validate_pair_groups(cli)?;
    }

    // Validate --list-sections conflicts
    if cli.list_sections && !cli.lines.is_empty() {
        return Err(UsageError("--list-sections cannot be combined with --line".into()).into());
    }
    if cli.list_sections && cli.force.is_some() {
        return Err(UsageError("--list-sections cannot be combined with --force".into()).into());
    }

    // Validate --eol value
    match cli.eol.as_str() {
        "preserve" | "lf" | "crlf" => {}
        other => {
            return Err(UsageError(format!(
                "Invalid --eol value '{}': must be preserve, lf, or crlf",
                other
            ))
            .into());
        }
    }

    let opts = ToggleOptions {
        force: &effective_force,
        mode: &effective_mode,
        temp_suffix: cli.temp_suffix.as_deref(),
        dry_run: cli.dry_run,
        backup: cli.backup.as_deref(),
        config: config.as_ref(),
        verbose: cli.verbose && !cli.json, // suppress verbose in JSON mode
        eol: &cli.eol,
        no_dereference: cli.no_dereference,
        encoding: &cli.encoding,
        json: cli.json,
        to_end: cli.to_end,
        comment_style_override: &cli.comment_style,
        interactive: cli.interactive,
    };

    if cli.list_sections {
        run_list_sections(cli, &opts)
    } else if cli.atomic {
        run_atomic(cli, &opts)
    } else if cli.json {
        run_json(cli, &opts)
    } else {
        run_normal(cli, &opts)
    }
}

/// Per PRD §0.13.4: error if any targeted group does not contain exactly 2 variants
/// in any input file. Runs before file mutation; failure leaves all files untouched.
fn validate_pair_groups(cli: &Cli) -> Result<()> {
    let walk_opts = walk::WalkOptions {
        verbose: cli.verbose,
        ..walk::WalkOptions::default()
    };
    let files = walk::collect_files(&cli.paths, cli.recursive, &walk_opts)?;

    for section in &cli.sections {
        let (group, _variant) = core::parse_id_parts(section);
        for file in &files {
            let content = match io::read_file_encoded(file, &cli.encoding) {
                Ok(c) => c,
                Err(_) => continue,
            };
            let count = core::discover_variants(&content, &group).len();
            if count != 2 {
                return Err(UsageError(format!(
                    "--pair: group '{group}' has {count} variants in {}, expected exactly 2",
                    file.display()
                ))
                .into());
            }
        }
    }
    Ok(())
}

/// Check if a file has any sections matching the requested IDs.
/// Returns true if at least one section matches, or if no section filter is active.
fn file_has_matching_sections(path: &Path, section_ids: &[String], encoding: &str) -> bool {
    if section_ids.is_empty() {
        return true;
    }
    let content = match io::read_file_encoded(path, encoding) {
        Ok(c) => c,
        Err(_) => return false,
    };
    let found = core::discover_sections(&content);
    section_ids.iter().any(|id| {
        let (group, variant) = core::parse_id_parts(id);
        found.iter().any(|s| match &variant {
            Some(v) => s.id == format!("{group}:{v}"),
            None => {
                // Solo or group: match exact id or any variant whose group equals our id.
                s.id == *id || core::parse_id_parts(&s.id).0 == group
            }
        })
    })
}

/// Collect files from CLI paths and apply recursive-mode filters
/// (section matching + extension support).
fn collect_and_filter_files(cli: &Cli, opts: &ToggleOptions) -> Result<Vec<PathBuf>> {
    let walk_opts = walk::WalkOptions {
        verbose: opts.verbose,
        skip_unsupported_extensions: false,
        ..walk::WalkOptions::default()
    };
    let files = walk::collect_files(&cli.paths, cli.recursive, &walk_opts)?;

    Ok(files
        .into_iter()
        .filter(|path| {
            // In recursive mode with sections, skip files without matching sections
            if cli.recursive
                && !cli.sections.is_empty()
                && !file_has_matching_sections(path, &cli.sections, opts.encoding)
            {
                return false;
            }
            // In recursive mode, silently skip files with unsupported extensions
            if cli.recursive
                && opts.comment_style_override.is_empty()
                && core::get_comment_style(path, opts.mode, opts.config).is_err()
            {
                return false;
            }
            true
        })
        .collect())
}

fn run_normal(cli: &Cli, opts: &ToggleOptions) -> Result<()> {
    let files = collect_and_filter_files(cli, opts)?;
    for path in &files {
        process_file(path, cli, opts)
            .with_context(|| format!("Failed to process {}", path.display()))?;
    }
    Ok(())
}

/// Atomic multi-file mode: compute all changes, stage them, then commit atomically.
fn run_atomic(cli: &Cli, opts: &ToggleOptions) -> Result<()> {
    let files = collect_and_filter_files(cli, opts)?;

    if files.is_empty() {
        return Ok(());
    }

    // Register signal handlers for graceful interrupt
    let interrupted = Arc::new(AtomicBool::new(false));
    let _ = signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(&interrupted));
    let _ = signal_hook::flag::register(signal_hook::consts::SIGINT, Arc::clone(&interrupted));

    // Determine backup behavior: --atomic implies --backup unless --no-backup
    let backup_enabled = !cli.no_backup;

    // Compute all changes first, collecting (path, original, modified) tuples
    let mut changes: Vec<(PathBuf, String, String)> = Vec::new();

    for path in &files {
        if interrupted.load(Ordering::Relaxed) {
            anyhow::bail!("Interrupted before staging. No files were modified.");
        }

        // Process each file to compute the modification (without writing)
        let original = io::read_file_encoded(path, opts.encoding)
            .with_context(|| format!("Failed to read {}", path.display()))?;

        let modified = compute_file_changes(path, cli, opts, &original)
            .with_context(|| format!("Failed to compute changes for {}", path.display()))?;

        if original != modified {
            changes.push((path.clone(), original, modified));
        }
    }

    if changes.is_empty() {
        if opts.verbose {
            eprintln!("No changes to apply.");
        }
        return Ok(());
    }

    if opts.verbose {
        eprintln!("Staging {} file(s) in atomic mode...", changes.len());
    }

    // Create atomic batch and stage all writes
    let target_paths: Vec<PathBuf> = changes.iter().map(|(p, _, _)| p.clone()).collect();
    let mut batch = io::AtomicBatch::new(&target_paths, backup_enabled, Arc::clone(&interrupted))
        .map_err(|e| anyhow::anyhow!("Failed to initialize atomic batch: {}", e))?;

    for (path, _original, modified) in &changes {
        let encoded = io::encode_for_atomic(modified, opts.encoding)
            .with_context(|| format!("Failed to encode content for {}", path.display()))?;

        batch
            .stage(path, &encoded, opts.encoding)
            .map_err(|e| anyhow::anyhow!("Failed to stage '{}': {}", path.display(), e))?;
    }

    if opts.verbose {
        eprintln!("All files staged. Committing...");
    }

    // Execute the two-phase commit
    batch
        .commit()
        .map_err(|e| anyhow::anyhow!("Atomic commit failed: {}", e))?;

    if opts.verbose {
        eprintln!(
            "Atomic commit successful. {} file(s) modified.",
            changes.len()
        );
    }

    // Print results in normal mode
    if !opts.json {
        for (path, original, modified) in &changes {
            let lines_changed = count_changed_lines(original, modified);
            eprintln!(
                "Modified {} ({} line(s) changed)",
                path.display(),
                lines_changed
            );
        }
    }

    Ok(())
}

/// Compute the modified content for a file without writing it.
/// This is the "dry" version of process_file used by atomic mode.
fn compute_file_changes(
    path: &Path,
    cli: &Cli,
    opts: &ToggleOptions,
    original: &str,
) -> Result<String> {
    // --strict-ext: reject non-.py files
    if cli.strict_ext {
        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
        if ext != "py" {
            return Err(UsageError(format!(
                "File '{}' is not a .py file (rejected by --strict-ext)",
                path.display()
            ))
            .into());
        }
    }

    let mut content = original.to_string();

    if !cli.lines.is_empty() {
        content = compute_line_range_changes(path, &cli.lines, opts, &content)?;
    }

    for section in &cli.sections {
        content = compute_section_changes(path, section, opts, &content)?;
    }

    Ok(content)
}

/// Compute line-range toggle changes without writing.
fn compute_line_range_changes(
    path: &Path,
    line_range_specs: &[String],
    opts: &ToggleOptions,
    content: &str,
) -> Result<String> {
    let comment_style = resolve_comment_style(path, opts)?;
    let line_count = content.lines().count();

    let mut ranges = Vec::new();
    for spec in line_range_specs {
        let (start_line, end_line) = core::parse_line_range(spec)?;
        if start_line > line_count {
            return Err(UsageError(format!(
                "Start line {} is out of range (file has {} lines)",
                start_line, line_count
            ))
            .into());
        }
        ranges.push(core::LineRange::new(start_line, end_line));
    }

    if opts.to_end {
        if let Some(last) = ranges.last_mut() {
            last.end = line_count;
        }
    }

    for range in &ranges {
        if range.end > line_count {
            return Err(UsageError(format!(
                "End line {} is out of range (file has {} lines)",
                range.end, line_count
            ))
            .into());
        }
    }

    let merged = core::merge_ranges(&ranges);
    let force_mode = opts.force.as_deref();
    let toggled = if opts.mode == "multi" {
        let (ms, me) = match (
            &comment_style.multi_line_start,
            &comment_style.multi_line_end,
        ) {
            (Some(s), Some(e)) => (s.as_str(), e.as_str()),
            _ => {
                return Err(UsageError(format!(
                    "Multi-line comments not supported for {}",
                    path.display()
                ))
                .into());
            }
        };
        core::toggle_comments_multi(content, &merged, force_mode, ms, me)
    } else {
        core::toggle_comments_with_marker(content, &merged, force_mode, &comment_style.single_line)
    };

    Ok(io::normalize_eol(&toggled, opts.eol))
}

/// Compute section toggle changes without writing.
/// Routes between solo, pair-flip, activate, force-all and 3+ error per PRD §0.13.3.
fn compute_section_changes(
    path: &Path,
    section_id: &str,
    opts: &ToggleOptions,
    content: &str,
) -> Result<String> {
    let comment_style = resolve_comment_style(path, opts)?;
    let (group, variant) = core::parse_id_parts(section_id);

    let toggled = match variant {
        Some(v) => core::activate_variant(content, &group, &v, &comment_style)?,
        None => {
            let variants = core::discover_variants(content, &group);
            // Solo path preserves prior behavior (no error if section missing — caller handles).
            if variants.len() <= 1 && opts.force.is_none() {
                let mut lines: Vec<String> = content.lines().map(String::from).collect();
                let result = core::find_and_toggle_section(
                    &mut lines,
                    section_id,
                    opts.force,
                    &comment_style,
                )?;
                if !result.modified {
                    return Ok(content.to_string());
                }
                let mut joined = lines.join("\n");
                if content.ends_with('\n') {
                    joined.push('\n');
                }
                joined
            } else {
                core::toggle_variant_group(content, &group, opts.force, &comment_style)?
            }
        }
    };

    Ok(io::normalize_eol(&toggled, opts.eol))
}

fn run_json(cli: &Cli, opts: &ToggleOptions) -> Result<()> {
    let files = collect_and_filter_files(cli, opts)?;
    let mut results: Vec<ToggleResult> = Vec::new();
    let mut had_error = false;

    for path in &files {
        match process_file(path, cli, opts) {
            Ok(proc_results) => {
                for pr in proc_results {
                    results.push(ToggleResult {
                        file: path.display().to_string(),
                        action: pr.action,
                        lines_changed: pr.lines_changed,
                        success: true,
                        error: None,
                        dry_run: opts.dry_run,
                        section_id: pr.section_id,
                        desc: pr.desc,
                    });
                }
            }
            Err(e) => {
                had_error = true;
                results.push(ToggleResult {
                    file: path.display().to_string(),
                    action: String::new(),
                    lines_changed: 0,
                    success: false,
                    error: Some(format!("{:#}", e)),
                    dry_run: opts.dry_run,
                    section_id: None,
                    desc: None,
                });
            }
        }
    }

    println!(
        "{}",
        serde_json::to_string(&results).expect("Failed to serialize JSON")
    );

    if had_error {
        // Return a generic error so main() sets a non-zero exit code
        anyhow::bail!("One or more files failed to process");
    }
    Ok(())
}

type SectionAggregation = (Option<String>, Vec<(String, usize, usize)>);

fn run_list_sections(cli: &Cli, opts: &ToggleOptions) -> Result<()> {
    let walk_opts = walk::WalkOptions {
        verbose: opts.verbose,
        skip_unsupported_extensions: false,
        ..walk::WalkOptions::default()
    };
    let files = walk::collect_files(&cli.paths, cli.recursive, &walk_opts)?;

    // Aggregate sections grouped by ID, preserving insertion order with BTreeMap
    let mut sections_by_id: BTreeMap<String, SectionAggregation> = BTreeMap::new();

    for path in &files {
        let content = match io::read_file_encoded(path, opts.encoding) {
            Ok(c) => c,
            Err(_) => continue,
        };
        let found = core::discover_sections(&content);
        for section in found {
            let entry = sections_by_id
                .entry(section.id.clone())
                .or_insert_with(|| (section.desc.clone(), Vec::new()));
            // If we haven't captured a desc yet, use this one
            if entry.0.is_none() && section.desc.is_some() {
                entry.0 = section.desc.clone();
            }
            entry.1.push((
                path.display().to_string(),
                section.start_line,
                section.end_line,
            ));
        }
    }

    if cli.json {
        let entries: Vec<SectionListEntry> = sections_by_id
            .into_iter()
            .map(|(id, (desc, files))| SectionListEntry {
                id,
                desc,
                files: files
                    .into_iter()
                    .map(|(file, start, end)| SectionFileEntry {
                        file,
                        start_line: start,
                        end_line: end,
                    })
                    .collect(),
            })
            .collect();
        println!(
            "{}",
            serde_json::to_string(&entries).expect("Failed to serialize JSON")
        );
    } else {
        for (id, (desc, locations)) in &sections_by_id {
            if let Some(d) = desc {
                println!("{} desc=\"{}\"", id, d);
            } else {
                println!("{}", id);
            }
            for (file, start, end) in locations {
                println!("  {}:{}-{}", file, start, end);
            }
        }
    }

    Ok(())
}

fn process_file(path: &Path, cli: &Cli, opts: &ToggleOptions) -> Result<Vec<ProcessResult>> {
    // --strict-ext: reject non-.py files
    if cli.strict_ext {
        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
        if ext != "py" {
            return Err(UsageError(format!(
                "File '{}' is not a .py file (rejected by --strict-ext)",
                path.display()
            ))
            .into());
        }
    }

    if opts.verbose {
        eprintln!("Processing {}:", path.display());
    }

    let mut results = Vec::new();

    if !cli.lines.is_empty() {
        if opts.verbose {
            for lr in &cli.lines {
                eprintln!("  Line range: {}", lr);
            }
        }
        let pr = toggle_line_ranges(path, &cli.lines, opts)?;
        results.push(pr);
    }

    for section in &cli.sections {
        if opts.verbose {
            eprintln!("  Section: {}", section);
        }
        let pr = toggle_section(path, section, opts)?;
        results.push(pr);
    }

    Ok(results)
}

/// Count the number of lines that differ between two strings.
fn count_changed_lines(original: &str, modified: &str) -> usize {
    let orig_lines: Vec<&str> = original.lines().collect();
    let mod_lines: Vec<&str> = modified.lines().collect();
    let max_len = orig_lines.len().max(mod_lines.len());
    let mut changed = 0;
    for i in 0..max_len {
        let a = orig_lines.get(i).copied().unwrap_or("");
        let b = mod_lines.get(i).copied().unwrap_or("");
        if a != b {
            changed += 1;
        }
    }
    changed
}

/// Apply changes to a file: handles dry-run, interactive prompt, backup, and write.
/// Returns the number of lines changed.
fn apply_changes(
    path: &Path,
    original: &str,
    modified: &str,
    opts: &ToggleOptions,
) -> Result<usize> {
    let lines_changed = count_changed_lines(original, modified);

    if opts.dry_run {
        if !opts.json {
            io::print_diff(path, original, modified);
        }
        if opts.interactive && std::io::stdin().is_terminal() {
            // In dry-run + interactive, just show the diff (already done above)
            eprintln!("(dry-run mode, no changes will be written)");
        }
        return Ok(lines_changed);
    }

    // Interactive prompt
    if opts.interactive {
        // Show diff preview before prompting (only on TTY to avoid polluting piped output)
        if std::io::stdin().is_terminal() && !opts.json {
            io::print_diff(path, original, modified);
        }
        eprint!("Modify {}? [y/N] ", path.display());
        use std::io::Write;
        std::io::stderr().flush().ok();
        let mut answer = String::new();
        std::io::stdin()
            .read_line(&mut answer)
            .map_err(|e| anyhow::anyhow!("Failed to read interactive input: {}", e))?;
        if !answer.trim().eq_ignore_ascii_case("y") {
            if opts.verbose {
                eprintln!("  Skipped {}", path.display());
            }
            return Ok(0);
        }
    }

    if let Some(ext) = opts.backup {
        io::create_backup(path, ext)?;
    }
    io::write_file_encoded(
        path,
        modified,
        opts.temp_suffix,
        opts.no_dereference,
        opts.encoding,
    )?;
    Ok(lines_changed)
}

/// Resolve comment style for a file, applying --comment-style override if present.
fn resolve_comment_style(path: &Path, opts: &ToggleOptions) -> Result<core::CommentStyle> {
    if !opts.comment_style_override.is_empty() {
        let single = opts.comment_style_override[0].clone();
        let (ms, me) = if opts.comment_style_override.len() == 3 {
            (
                Some(opts.comment_style_override[1].clone()),
                Some(opts.comment_style_override[2].clone()),
            )
        } else {
            (None, None)
        };
        return Ok(core::CommentStyle {
            single_line: single,
            multi_line_start: ms,
            multi_line_end: me,
        });
    }
    core::get_comment_style(path, opts.mode, opts.config)
}

fn toggle_line_ranges(
    path: &Path,
    line_range_specs: &[String],
    opts: &ToggleOptions,
) -> Result<ProcessResult> {
    let comment_style = resolve_comment_style(path, opts)?;
    let content = io::read_file_encoded(path, opts.encoding)?;
    let line_count = content.lines().count();

    // Parse all range specs into LineRange values
    let mut ranges = Vec::new();
    for spec in line_range_specs {
        let (start_line, end_line) = core::parse_line_range(spec)?;
        if start_line > line_count {
            return Err(UsageError(format!(
                "Start line {} is out of range (file has {} lines)",
                start_line, line_count
            ))
            .into());
        }
        ranges.push(core::LineRange::new(start_line, end_line));
    }

    // --to-end: extend the last range's end to the file's line count
    if opts.to_end {
        if let Some(last) = ranges.last_mut() {
            last.end = line_count;
        }
    }

    // Validate end lines against file length (after --to-end extension)
    for range in &ranges {
        if range.end > line_count {
            return Err(UsageError(format!(
                "End line {} is out of range (file has {} lines)",
                range.end, line_count
            ))
            .into());
        }
    }

    let merged = core::merge_ranges(&ranges);
    let force_mode = opts.force.as_deref();
    let toggled = if opts.mode == "multi" {
        let (ms, me) = match (
            &comment_style.multi_line_start,
            &comment_style.multi_line_end,
        ) {
            (Some(s), Some(e)) => (s.as_str(), e.as_str()),
            _ => {
                return Err(UsageError(format!(
                    "Multi-line comments not supported for {}",
                    path.display()
                ))
                .into());
            }
        };
        core::toggle_comments_multi(&content, &merged, force_mode, ms, me)
    } else {
        core::toggle_comments_with_marker(&content, &merged, force_mode, &comment_style.single_line)
    };
    let result = io::normalize_eol(&toggled, opts.eol);
    let lines_changed = apply_changes(path, &content, &result, opts)?;

    Ok(ProcessResult {
        action: "toggle_line_range".to_string(),
        lines_changed,
        section_id: None,
        desc: None,
    })
}

fn toggle_section(path: &Path, section_id: &str, opts: &ToggleOptions) -> Result<ProcessResult> {
    if opts.verbose {
        eprintln!("  Looking for section with ID={}", section_id);
    }

    let original_content = io::read_file_encoded(path, opts.encoding)?;
    let modified = compute_section_changes(path, section_id, opts, &original_content)?;

    let lines_changed = if modified == original_content {
        if opts.verbose {
            eprintln!("  No changes made to file");
        }
        0
    } else {
        if opts.verbose {
            eprintln!("  File modified, writing changes back");
        }
        apply_changes(path, &original_content, &modified, opts)?
    };

    // Resolve desc by looking up the matching section/variant.
    let (group, variant) = core::parse_id_parts(section_id);
    let desc = core::discover_variants(&original_content, &group)
        .into_iter()
        .find(|s| match &variant {
            Some(v) => s.id == format!("{group}:{v}"),
            None => s.id == section_id || core::parse_id_parts(&s.id).1.is_some(),
        })
        .and_then(|s| s.desc);

    if opts.verbose {
        if let Some(ref d) = desc {
            eprintln!("  Section desc: {}", d);
        }
    }

    Ok(ProcessResult {
        action: "toggle_section".to_string(),
        lines_changed,
        section_id: Some(section_id.to_string()),
        desc,
    })
}

fn run_scan(cli: &Cli) -> Result<()> {
    let walk_opts = walk::WalkOptions {
        verbose: cli.verbose,
        ..walk::WalkOptions::default()
    };
    // --scan is implicitly recursive
    let files = walk::collect_files(&cli.paths, true, &walk_opts)?;

    // Warn about paths that don't exist on disk
    for path in &cli.paths {
        if !path.exists() {
            eprintln!("Warning: '{}' does not exist", path.display());
        }
    }

    let mut all_sections: Vec<core::ScanSectionInfo> = Vec::new();
    for file_path in &files {
        match io::read_file_encoded(file_path, &cli.encoding) {
            Ok(content) => {
                let sections = core::scan_sections(file_path, &content);
                all_sections.extend(sections);
            }
            Err(e) => {
                if cli.verbose {
                    eprintln!("Warning: skipping {}: {}", file_path.display(), e);
                }
            }
        }
    }

    if cli.check {
        let mut per_file: BTreeMap<PathBuf, Vec<core::ScanSectionInfo>> = BTreeMap::new();
        for s in &all_sections {
            per_file
                .entry(PathBuf::from(&s.file))
                .or_default()
                .push(s.clone());
        }
        let per_file_vec: Vec<_> = per_file.into_iter().collect();
        let issues = core::validate_sections(&per_file_vec, cli.pair);

        if cli.json {
            println!(
                "{}",
                serde_json::to_string_pretty(&issues).expect("Failed to serialize JSON")
            );
        } else {
            print_check_results(&issues);
        }

        if check_has_errors(&issues) {
            return Err(anyhow::anyhow!("validation failed"));
        }
        return Ok(());
    }

    if cli.json {
        let root = core::build_scan_json(&all_sections);
        println!(
            "{}",
            serde_json::to_string_pretty(&root).expect("Failed to serialize JSON")
        );
    } else if !cli.sections.is_empty() {
        print_scan_detailed(&all_sections, &cli.sections);
    } else if cli.recursive {
        print_scan_summary(&all_sections);
    } else {
        print_scan_results(&all_sections);
    }
    Ok(())
}

fn print_check_results(issues: &[core::CheckIssue]) {
    if issues.is_empty() {
        println!("OK    no issues found");
        return;
    }
    for i in issues {
        let tag = match i.level {
            core::CheckLevel::Ok => "OK  ",
            core::CheckLevel::Warn => "WARN",
            core::CheckLevel::Err => "ERR ",
        };
        let file_part = i
            .file
            .as_deref()
            .map(|f| format!(" ({f})"))
            .unwrap_or_default();
        println!("{tag}  {:<18} {}{file_part}", i.group, i.message);
    }
}

fn check_has_errors(issues: &[core::CheckIssue]) -> bool {
    issues
        .iter()
        .any(|i| matches!(i.level, core::CheckLevel::Err))
}

fn print_scan_summary(sections: &[core::ScanSectionInfo]) {
    if sections.is_empty() {
        println!("No toggle sections found.");
        return;
    }
    println!(
        "{:<20} {:<7} {:<7} {:<10} STATE",
        "SECTION", "TYPE", "FILES", "VARIANTS"
    );
    println!("{}", "\u{2500}".repeat(60));

    for s in core::summarize_scan(sections) {
        let type_label = section_type_label(&s.section_type);
        let variants = if matches!(s.section_type, core::SectionType::Solo) {
            "—".to_string()
        } else {
            s.variant_count.to_string()
        };
        println!(
            "{:<20} {:<7} {:<7} {:<10} {}",
            s.group, type_label, s.file_count, variants, s.state
        );
    }
}

fn print_scan_detailed(sections: &[core::ScanSectionInfo], ids: &[String]) {
    use std::collections::BTreeMap;
    for id in ids {
        let (group, variant) = core::parse_id_parts(id);
        let in_scope: Vec<&core::ScanSectionInfo> = sections
            .iter()
            .filter(|s| {
                s.group == group
                    && match &variant {
                        Some(v) => s.variant.as_deref() == Some(v.as_str()),
                        None => true,
                    }
            })
            .collect();

        if in_scope.is_empty() {
            println!("No sections found for '{id}'.");
            continue;
        }

        if let Some(sum) = core::summarize_scan(sections)
            .into_iter()
            .find(|s| s.group == group)
        {
            println!(
                "GROUP: {} ({}, {} variants)\n",
                sum.group,
                section_type_label(&sum.section_type),
                sum.variant_count.max(1)
            );
        }

        let mut by_id: BTreeMap<&String, Vec<&core::ScanSectionInfo>> = BTreeMap::new();
        for s in in_scope {
            by_id.entry(&s.id).or_default().push(s);
        }
        for (vid, items) in by_id {
            let state = items[0].state.clone();
            println!("  {vid} [{state}]");
            for it in items {
                let end = it.end_line.map_or("?".to_string(), |e| e.to_string());
                println!("    {:<40} lines {}-{}", it.file, it.start_line, end);
            }
            println!();
        }
    }
}

fn section_type_label(t: &core::SectionType) -> &'static str {
    match t {
        core::SectionType::Solo => "solo",
        core::SectionType::Pair => "pair",
        core::SectionType::Group => "group",
    }
}

fn print_scan_results(sections: &[core::ScanSectionInfo]) {
    if sections.is_empty() {
        println!("No toggle sections found.");
        return;
    }

    println!(
        "{:<20} {:<7} {:<12} {:<14} DESCRIPTION",
        "SECTION", "TYPE", "STATE", "LINES"
    );
    println!("{}", "\u{2500}".repeat(80));

    let summaries = core::summarize_scan(sections);
    for summary in &summaries {
        let mut items: Vec<&core::ScanSectionInfo> = sections
            .iter()
            .filter(|s| s.group == summary.group)
            .collect();
        items.sort_by_key(|s| (s.file.clone(), s.start_line));

        let type_label = section_type_label(&summary.section_type);
        for s in items {
            let lines = match s.end_line {
                Some(e) => format!("{}-{}", s.start_line, e),
                None => format!("{}-?", s.start_line),
            };
            let desc = s.description.as_deref().unwrap_or("");
            println!(
                "{:<20} {:<7} {:<12} {:<14} {}",
                s.id, type_label, s.state, lines, desc
            );
        }
    }
}
