use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use std::fs;
use tempfile::TempDir;

#[allow(deprecated)]
fn cmd() -> Command {
    Command::cargo_bin("toggle").unwrap()
}

fn setup_temp_file(content: &str, filename: &str) -> (TempDir, std::path::PathBuf) {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join(filename);
    fs::write(&path, content).unwrap();
    (dir, path)
}

/// Create a temp directory with multiple files, supporting subdirectories.
fn setup_temp_dir_with_files(files: &[(&str, &str)]) -> TempDir {
    let dir = TempDir::new().unwrap();
    for (name, content) in files {
        let path = dir.path().join(name);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(&path, content).unwrap();
    }
    dir
}

// ── Repeatable --line ranges (Phase 2) ──

#[test]
fn test_multiple_line_ranges_non_adjacent() {
    let (_dir, path) = setup_temp_file("a\nb\nc\nd\ne\nf\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "2:3", "-l", "5:6"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert_eq!(result, "a\n# b\n# c\nd\n# e\n# f\n");
}

#[test]
fn test_multiple_line_ranges_overlapping() {
    let (_dir, path) = setup_temp_file("a\nb\nc\nd\ne\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:3", "-l", "2:4"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    // Lines 1-4 should all be commented (merged ranges)
    assert_eq!(result, "# a\n# b\n# c\n# d\ne\n");
}

// ── --to-end (Phase 2) ──

#[test]
fn test_to_end_extends_to_eof() {
    let (_dir, path) = setup_temp_file("a\nb\nc\nd\ne\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "3", "--to-end"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert_eq!(result, "a\nb\n# c\n# d\n# e\n");
}

#[test]
fn test_to_end_without_line_errors() {
    let (_dir, path) = setup_temp_file("a\nb\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "--to-end"])
        .assert()
        .failure()
        .stderr(predicates::str::contains("--to-end requires"));
}

// ── Multi-line comment support (Phase 2) ──

#[test]
fn test_multi_line_mode_wraps_in_block_comment() {
    let (_dir, path) = setup_temp_file("line1\nline2\nline3\nline4\n", "test.js");
    cmd()
        .args([path.to_str().unwrap(), "-l", "2:3", "-m", "multi"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert!(result.contains("/*"), "should contain block comment start");
    assert!(result.contains("*/"), "should contain block comment end");
}

#[test]
fn test_multi_line_mode_unwraps_block_comment() {
    let (_dir, path) = setup_temp_file("line1\n/* line2\nline3 */\nline4\n", "test.js");
    cmd()
        .args([path.to_str().unwrap(), "-l", "2:3", "-m", "multi"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert!(!result.contains("/*"), "should unwrap block comment start");
    assert!(!result.contains("*/"), "should unwrap block comment end");
}

#[test]
fn test_multi_line_mode_unsupported_for_python() {
    let (_dir, path) = setup_temp_file("hello\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:1", "-m", "multi"])
        .assert()
        .failure()
        .stderr(predicates::str::contains(
            "Multi-line comments not supported",
        ));
}

// ── --comment-style override (Phase 2) ──

#[test]
fn test_comment_style_single_override() {
    let (_dir, path) = setup_temp_file("hello\nworld\n", "test.txt");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:2", "--comment-style", "//"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert!(
        result.contains("// hello"),
        "should use custom // delimiter"
    );
}

#[test]
fn test_comment_style_with_multi_line() {
    let (_dir, path) = setup_temp_file("a\nb\nc\n", "test.txt");
    cmd()
        .args([
            path.to_str().unwrap(),
            "-l",
            "1:2",
            "-m",
            "multi",
            "--comment-style",
            "//",
            "/*",
            "*/",
        ])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert!(result.contains("/*"), "should use custom multi-line start");
    assert!(result.contains("*/"), "should use custom multi-line end");
}

#[test]
fn test_comment_style_two_values_errors() {
    let (_dir, path) = setup_temp_file("hello\n", "test.py");
    cmd()
        .args([
            path.to_str().unwrap(),
            "-l",
            "1:1",
            "--comment-style",
            "//",
            "/*",
        ])
        .assert()
        .failure()
        .stderr(predicates::str::contains("--comment-style requires"));
}

// ── --interactive (Phase 2) ──

#[test]
fn test_interactive_yes_modifies_file() {
    let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:2", "--interactive"])
        .write_stdin("y\n")
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert!(result.contains("#"), "file should be modified on 'y'");
}

#[test]
fn test_interactive_no_skips_modification() {
    let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
    let original = fs::read_to_string(&path).unwrap();
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:2", "--interactive"])
        .write_stdin("n\n")
        .assert()
        .success();
    let after = fs::read_to_string(&path).unwrap();
    assert_eq!(
        original, after,
        "file should not be modified when user answers 'n'"
    );
}

// ── Line range toggling ──

#[test]
fn test_toggle_line_range_comments_lines() {
    let (_dir, path) = setup_temp_file("line1\nline2\nline3\nline4\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "2:3"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert!(result.contains("line1\n"));
    assert!(result.contains("#"));
    assert!(result.contains("line4\n"));
}

#[test]
fn test_toggle_force_on() {
    let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:2", "-f", "on"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert!(result.contains("#"));
}

#[test]
fn test_toggle_force_off() {
    let (_dir, path) = setup_temp_file("# hello\n# world\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:2", "-f", "off"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert!(!result.contains("#"));
}

// ── Section toggling ──

#[test]
fn test_toggle_section() {
    let content = "before\n# toggle:start ID=test\nhello\n# toggle:end ID=test\nafter\n";
    let (_dir, path) = setup_temp_file(content, "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-S", "test", "-f", "on"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert!(result.contains("#hello") || result.contains("# hello"));
}

// ── --strict-ext ──

#[test]
fn test_strict_ext_rejects_non_py() {
    let (_dir, path) = setup_temp_file("console.log('hi')\n", "test.js");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:1", "--strict-ext"])
        .assert()
        .failure();
}

#[test]
fn test_strict_ext_accepts_py() {
    let (_dir, path) = setup_temp_file("print('hi')\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:1", "--strict-ext"])
        .assert()
        .success();
}

// ── --verbose ──

#[test]
fn test_verbose_outputs_to_stderr() {
    let (_dir, path) = setup_temp_file("print('hi')\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:1", "-v"])
        .assert()
        .success()
        .stderr(predicates::str::contains("Processing"));
}

// ── Multi-language support ──

#[test]
fn test_toggle_javascript() {
    let (_dir, path) = setup_temp_file("console.log('hi');\n", "test.js");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:1"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert!(result.contains("//"));
}

#[test]
fn test_toggle_shell() {
    let (_dir, path) = setup_temp_file("echo hello\n", "test.sh");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:1"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert!(result.contains("#"));
}

// ── Out-of-range lines ──

#[test]
fn test_out_of_range_line_errors() {
    let (_dir, path) = setup_temp_file("line1\nline2\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "100:105"])
        .assert()
        .failure()
        .stderr(predicates::str::contains("out of range"));
}

// ── Missing file ──

#[test]
fn test_nonexistent_file() {
    cmd()
        .args(["/tmp/nonexistent_file_toggle_test.py", "-l", "1:1"])
        .assert()
        .failure();
}

// ── --dry-run ──

#[test]
fn test_dry_run_shows_diff_and_does_not_modify_file() {
    let (_dir, path) = setup_temp_file("line1\nline2\nline3\n", "test.py");
    let original = fs::read_to_string(&path).unwrap();
    cmd()
        .args([path.to_str().unwrap(), "-l", "2:3", "--dry-run"])
        .assert()
        .success()
        .stdout(predicates::str::contains("---"))
        .stdout(predicates::str::contains("+++"))
        .stdout(predicates::str::contains("@@"));
    let after = fs::read_to_string(&path).unwrap();
    assert_eq!(
        original, after,
        "file should not be modified in dry-run mode"
    );
}

#[test]
fn test_dry_run_no_changes_empty_stdout() {
    let (_dir, path) = setup_temp_file("# already commented\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:1", "-f", "on", "--dry-run"])
        .assert()
        .success()
        .stdout(predicates::str::is_empty().not().or(predicate::always()));
    // File should not be modified
    let after = fs::read_to_string(&path).unwrap();
    assert_eq!(after, "# already commented\n");
}

#[test]
fn test_dry_run_with_verbose() {
    let (_dir, path) = setup_temp_file("line1\nline2\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:2", "--dry-run", "-v"])
        .assert()
        .success()
        .stderr(predicates::str::contains("Processing"));
}

// ── --backup ──

#[test]
fn test_backup_creates_backup_file() {
    let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
    let original = fs::read_to_string(&path).unwrap();
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:2", "--backup", ".bak"])
        .assert()
        .success();
    let backup_path = path.with_file_name("test.py.bak");
    assert!(backup_path.exists(), "backup file should exist");
    let backup_content = fs::read_to_string(&backup_path).unwrap();
    assert_eq!(
        backup_content, original,
        "backup should contain original content"
    );
    let modified = fs::read_to_string(&path).unwrap();
    assert!(modified.contains("#"), "original file should be toggled");
}

#[test]
fn test_backup_with_dry_run_skips_backup() {
    let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
    cmd()
        .args([
            path.to_str().unwrap(),
            "-l",
            "1:2",
            "--backup",
            ".bak",
            "--dry-run",
        ])
        .assert()
        .success();
    let backup_path = path.with_file_name("test.py.bak");
    assert!(
        !backup_path.exists(),
        "backup should not be created in dry-run mode"
    );
}

// ── --config ──

#[test]
fn test_config_custom_delimiter() {
    let dir = TempDir::new().unwrap();
    let config_path = dir.path().join(".toggleConfig");
    fs::write(
        &config_path,
        r#"
[language.python]
single_line_delimiter = ";;"
"#,
    )
    .unwrap();
    let file_path = dir.path().join("test.py");
    fs::write(&file_path, "hello\nworld\n").unwrap();
    cmd()
        .args([
            file_path.to_str().unwrap(),
            "-l",
            "1:2",
            "--config",
            config_path.to_str().unwrap(),
        ])
        .assert()
        .success();
    let result = fs::read_to_string(&file_path).unwrap();
    assert!(
        result.contains(";;"),
        "should use custom delimiter from config"
    );
}

#[test]
fn test_config_cli_force_overrides_config() {
    let dir = TempDir::new().unwrap();
    let config_path = dir.path().join(".toggleConfig");
    fs::write(
        &config_path,
        r#"
[global]
force_state = "on"
"#,
    )
    .unwrap();
    let file_path = dir.path().join("test.py");
    fs::write(&file_path, "# commented\n").unwrap();
    cmd()
        .args([
            file_path.to_str().unwrap(),
            "-l",
            "1:1",
            "-f",
            "off",
            "--config",
            config_path.to_str().unwrap(),
        ])
        .assert()
        .success();
    let result = fs::read_to_string(&file_path).unwrap();
    assert!(
        !result.contains("#"),
        "CLI --force off should override config force_state=on"
    );
}

#[test]
fn test_config_nonexistent_file_errors() {
    let (_dir, path) = setup_temp_file("hello\n", "test.py");
    cmd()
        .args([
            path.to_str().unwrap(),
            "-l",
            "1:1",
            "--config",
            "/tmp/nonexistent_toggle_config",
        ])
        .assert()
        .failure();
}

#[test]
fn test_config_global_default_mode() {
    let dir = TempDir::new().unwrap();
    let config_path = dir.path().join(".toggleConfig");
    fs::write(
        &config_path,
        r#"
[global]
force_state = "on"
"#,
    )
    .unwrap();
    let file_path = dir.path().join("test.py");
    fs::write(&file_path, "hello\n").unwrap();
    cmd()
        .args([
            file_path.to_str().unwrap(),
            "-l",
            "1:1",
            "--config",
            config_path.to_str().unwrap(),
        ])
        .assert()
        .success();
    let result = fs::read_to_string(&file_path).unwrap();
    assert!(
        result.contains("# hello"),
        "config force_state=on should comment the line"
    );
}

// ── --eol ──

#[test]
fn test_eol_lf_normalizes_crlf() {
    let (_dir, path) = setup_temp_file("line1\r\nline2\r\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:1", "--eol", "lf"])
        .assert()
        .success();
    let result = fs::read(&path).unwrap();
    assert!(
        !result.windows(2).any(|w| w == b"\r\n"),
        "should have no CRLF after --eol lf"
    );
}

#[test]
fn test_eol_crlf_normalizes_lf() {
    let (_dir, path) = setup_temp_file("line1\nline2\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:1", "--eol", "crlf"])
        .assert()
        .success();
    let result = fs::read(&path).unwrap();
    let content = String::from_utf8(result).unwrap();
    assert!(
        content.contains("\r\n"),
        "should have CRLF after --eol crlf"
    );
}

#[test]
fn test_eol_preserve_keeps_original() {
    let original = "line1\nline2\n";
    let (_dir, path) = setup_temp_file(original, "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:1", "--eol", "preserve"])
        .assert()
        .success();
    let result = fs::read(&path).unwrap();
    // Verify no \r was introduced
    assert!(!result.contains(&b'\r'), "preserve should not introduce CR");
}

// ── --no-dereference ──

#[cfg(unix)]
#[test]
fn test_no_dereference_preserves_symlink() {
    use std::os::unix::fs::symlink;
    let dir = TempDir::new().unwrap();
    let target = dir.path().join("target.py");
    fs::write(&target, "hello\nworld\n").unwrap();
    let link = dir.path().join("link.py");
    symlink(&target, &link).unwrap();

    cmd()
        .args([link.to_str().unwrap(), "-l", "1:2", "-N"])
        .assert()
        .success();

    // Symlink should still be a symlink
    assert!(
        link.symlink_metadata().unwrap().file_type().is_symlink(),
        "symlink should be preserved with -N"
    );
    // Target file should be modified
    let result = fs::read_to_string(&target).unwrap();
    assert!(result.contains("#"), "target should be toggled");
}

#[cfg(unix)]
#[test]
fn test_default_replaces_symlink() {
    use std::os::unix::fs::symlink;
    let dir = TempDir::new().unwrap();
    let target = dir.path().join("target.py");
    fs::write(&target, "hello\nworld\n").unwrap();
    let link = dir.path().join("link.py");
    symlink(&target, &link).unwrap();

    cmd()
        .args([link.to_str().unwrap(), "-l", "1:2"])
        .assert()
        .success();

    // Without -N, the symlink gets replaced by the atomic rename
    assert!(
        !link.symlink_metadata().unwrap().file_type().is_symlink(),
        "without -N, symlink should be replaced by a regular file"
    );
}

#[test]
fn test_eol_invalid_value_errors() {
    let (_dir, path) = setup_temp_file("hello\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:1", "--eol", "foo"])
        .assert()
        .failure()
        .stderr(predicates::str::contains("Invalid --eol value"));
}

// ── --encoding ──

#[test]
fn test_encoding_latin1_roundtrip() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("test.py");
    // "café\n" in Latin-1
    fs::write(&path, [0x63, 0x61, 0x66, 0xe9, 0x0a]).unwrap();
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:1", "-e", "latin-1"])
        .assert()
        .success();
    let bytes = fs::read(&path).unwrap();
    // Should have comment marker "# " (0x23 0x20) before "café"
    assert!(
        bytes.windows(2).any(|w| w == [0x23, 0x20]),
        "should have # comment marker"
    );
    // Should still contain the é character (0xe9) in Latin-1
    assert!(bytes.contains(&0xe9), "should preserve Latin-1 encoding");
}

#[test]
fn test_encoding_default_utf8() {
    let (_dir, path) = setup_temp_file("hello\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:1"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert!(result.contains("#"), "default UTF-8 should work as before");
}

#[test]
fn test_encoding_invalid_errors() {
    let (_dir, path) = setup_temp_file("hello\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-l", "1:1", "-e", "bogus-codec"])
        .assert()
        .failure()
        .stderr(predicates::str::contains("Unsupported encoding"));
}

#[test]
fn test_config_global_delimiter_fallback() {
    let dir = TempDir::new().unwrap();
    let config_path = dir.path().join(".toggleConfig");
    fs::write(
        &config_path,
        r#"
[global]
single_line_delimiter = "%%"
"#,
    )
    .unwrap();
    // Use .py which normally uses #, but global override should use %%
    let file_path = dir.path().join("test.py");
    fs::write(&file_path, "hello\n").unwrap();
    cmd()
        .args([
            file_path.to_str().unwrap(),
            "-l",
            "1:1",
            "--config",
            config_path.to_str().unwrap(),
        ])
        .assert()
        .success();
    let result = fs::read_to_string(&file_path).unwrap();
    assert!(
        result.contains("%%"),
        "global single_line_delimiter should override default when no language-specific config"
    );
}

// ── --json ──

#[test]
fn test_json_output_valid_json() {
    let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
    let output = cmd()
        .args([path.to_str().unwrap(), "-l", "1:2", "--json"])
        .output()
        .unwrap();
    assert!(output.status.success());
    let json: Value = serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
    assert!(json.is_array(), "JSON output should be an array");
}

#[test]
fn test_json_output_contains_fields() {
    let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
    let output = cmd()
        .args([path.to_str().unwrap(), "-l", "1:2", "--json"])
        .output()
        .unwrap();
    assert!(output.status.success());
    let json: Vec<Value> = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(json.len(), 1);
    let entry = &json[0];
    assert!(entry["file"].as_str().unwrap().contains("test.py"));
    assert_eq!(entry["action"], "toggle_line_range");
    assert!(entry["lines_changed"].as_u64().unwrap() > 0);
    assert_eq!(entry["success"], true);
    assert!(entry.get("error").is_none() || entry["error"].is_null());
    assert_eq!(entry["dry_run"], false);
}

#[test]
fn test_json_output_error_case() {
    let output = cmd()
        .args([
            "/tmp/nonexistent_toggle_json_test.py",
            "-l",
            "1:1",
            "--json",
        ])
        .output()
        .unwrap();
    assert!(!output.status.success());
    let json: Vec<Value> =
        serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON even on error");
    assert_eq!(json.len(), 1);
    assert_eq!(json[0]["success"], false);
    assert!(!json[0]["error"].as_str().unwrap().is_empty());
}

#[test]
fn test_json_suppresses_verbose() {
    let (_dir, path) = setup_temp_file("hello\n", "test.py");
    let output = cmd()
        .args([path.to_str().unwrap(), "-l", "1:1", "--json", "-v"])
        .output()
        .unwrap();
    assert!(output.status.success());
    assert!(
        output.stderr.is_empty(),
        "verbose output should be suppressed in JSON mode"
    );
    // stdout should still be valid JSON
    let _: Vec<Value> = serde_json::from_slice(&output.stdout).unwrap();
}

#[test]
fn test_json_with_dry_run() {
    let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
    let output = cmd()
        .args([path.to_str().unwrap(), "-l", "1:2", "--json", "--dry-run"])
        .output()
        .unwrap();
    assert!(output.status.success());
    // stdout should be only JSON, no diff output mixed in
    let json: Vec<Value> = serde_json::from_slice(&output.stdout)
        .expect("stdout should be pure JSON with no diff mixed in");
    assert_eq!(json[0]["dry_run"], true);
    // File should not be modified
    let after = fs::read_to_string(&path).unwrap();
    assert_eq!(after, "hello\nworld\n");
}

// ── -F alias and --force invert value ───────────────────────────────────────

#[test]
fn test_force_uppercase_f_alias_on() {
    let (_dir, path) = setup_temp_file("a\nb\nc\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-F", "on", "-l", "1:2"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert_eq!(result, "# a\n# b\nc\n");
}

#[test]
fn test_force_uppercase_f_alias_off() {
    let (_dir, path) = setup_temp_file("# a\n# b\nc\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-F", "off", "-l", "1:2"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert_eq!(result, "a\nb\nc\n");
}

#[test]
fn test_force_invert_value_comments_then_uncomments() {
    let (_dir, path) = setup_temp_file("a\nb\nc\n", "test.py");
    // First pass: invert on uncommented lines → comments them
    cmd()
        .args([path.to_str().unwrap(), "-f", "invert", "-l", "1:2"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert_eq!(result, "# a\n# b\nc\n");
    // Second pass: invert on commented lines → uncomments them
    cmd()
        .args([path.to_str().unwrap(), "--force", "invert", "-l", "1:2"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert_eq!(result, "a\nb\nc\n");
}

#[test]
fn test_force_invert_with_uppercase_f() {
    let (_dir, path) = setup_temp_file("a\nb\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "-F", "invert", "-l", "1:2"])
        .assert()
        .success();
    let result = fs::read_to_string(&path).unwrap();
    assert_eq!(result, "# a\n# b\n");
}

#[test]
fn test_force_invalid_value_errors() {
    let (_dir, path) = setup_temp_file("a\nb\n", "test.py");
    cmd()
        .args([path.to_str().unwrap(), "--force", "bogus", "-l", "1:2"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("Invalid --force value 'bogus'"));
}

// ── Unknown extension fallback to config ────────────────────────────────────

#[test]
fn test_unknown_extension_with_global_config_delimiter() {
    let dir = TempDir::new().unwrap();
    let file_path = dir.path().join("data.xyz");
    fs::write(&file_path, "line one\nline two\n").unwrap();

    let config_path = dir.path().join(".toggleConfig");
    fs::write(&config_path, "[global]\nsingle_line_delimiter = \"//\"\n").unwrap();

    cmd()
        .args([
            file_path.to_str().unwrap(),
            "-l",
            "1:2",
            "--config",
            config_path.to_str().unwrap(),
        ])
        .assert()
        .success();
    let result = fs::read_to_string(&file_path).unwrap();
    assert_eq!(result, "// line one\n// line two\n");
}

#[test]
fn test_unknown_extension_without_config_errors() {
    let dir = TempDir::new().unwrap();
    let file_path = dir.path().join("data.xyz");
    fs::write(&file_path, "line one\nline two\n").unwrap();

    cmd()
        .args([file_path.to_str().unwrap(), "-l", "1:2"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("Unsupported file extension: .xyz"));
}

#[test]
fn test_unknown_extension_error_suggests_alternatives() {
    let dir = TempDir::new().unwrap();
    let file_path = dir.path().join("data.xyz");
    fs::write(&file_path, "line one\n").unwrap();

    cmd()
        .args([file_path.to_str().unwrap(), "-l", "1:1"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("--comment-style"))
        .stderr(predicate::str::contains("--config"));
}

// ── -R/--recursive directory traversal ──

#[test]
fn test_recursive_toggles_all_files_in_directory() {
    let dir = setup_temp_dir_with_files(&[("a.py", "hello\nworld\n"), ("b.py", "foo\nbar\n")]);
    cmd()
        .args([dir.path().to_str().unwrap(), "-l", "1:2", "-R"])
        .assert()
        .success();
    let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
    let b = fs::read_to_string(dir.path().join("b.py")).unwrap();
    assert!(a.contains("# hello"), "a.py should be commented");
    assert!(b.contains("# foo"), "b.py should be commented");
}

#[test]
fn test_recursive_traverses_subdirectories() {
    let dir = setup_temp_dir_with_files(&[
        ("top.py", "top\n"),
        ("sub/nested.py", "nested\n"),
        ("sub/deep/deep.py", "deep\n"),
    ]);
    cmd()
        .args([dir.path().to_str().unwrap(), "-l", "1:1", "-R"])
        .assert()
        .success();
    assert!(fs::read_to_string(dir.path().join("top.py"))
        .unwrap()
        .contains("# top"));
    assert!(fs::read_to_string(dir.path().join("sub/nested.py"))
        .unwrap()
        .contains("# nested"));
    assert!(fs::read_to_string(dir.path().join("sub/deep/deep.py"))
        .unwrap()
        .contains("# deep"));
}

// ── Cross-file section toggling ──

#[test]
fn test_recursive_cross_file_section_toggle() {
    let dir = setup_temp_dir_with_files(&[
        (
            "a.py",
            "before\n# toggle:start ID=feat1\nhello\n# toggle:end ID=feat1\nafter\n",
        ),
        (
            "sub/b.py",
            "top\n# toggle:start ID=feat1\nworld\n# toggle:end ID=feat1\nbottom\n",
        ),
        (
            "sub/deep/c.py",
            "x\n# toggle:start ID=feat1\nfoo\n# toggle:end ID=feat1\ny\n",
        ),
    ]);
    cmd()
        .args([
            dir.path().to_str().unwrap(),
            "-R",
            "-S",
            "feat1",
            "-f",
            "on",
        ])
        .assert()
        .success();
    let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
    let b = fs::read_to_string(dir.path().join("sub/b.py")).unwrap();
    let c = fs::read_to_string(dir.path().join("sub/deep/c.py")).unwrap();
    assert!(a.contains("# hello"), "a.py section should be commented");
    assert!(
        b.contains("# world"),
        "sub/b.py section should be commented"
    );
    assert!(
        c.contains("# foo"),
        "sub/deep/c.py section should be commented"
    );
}

#[test]
fn test_recursive_skips_unsupported_extensions() {
    let dir = setup_temp_dir_with_files(&[
        ("code.py", "hello\n"),
        ("readme.md", "# Title\n"),
        ("data.csv", "a,b,c\n"),
    ]);
    cmd()
        .args([dir.path().to_str().unwrap(), "-l", "1:1", "-R"])
        .assert()
        .success();
    // .py should be toggled
    assert!(fs::read_to_string(dir.path().join("code.py"))
        .unwrap()
        .contains("# hello"));
    // .md and .csv should be untouched
    assert_eq!(
        fs::read_to_string(dir.path().join("readme.md")).unwrap(),
        "# Title\n"
    );
    assert_eq!(
        fs::read_to_string(dir.path().join("data.csv")).unwrap(),
        "a,b,c\n"
    );
}

#[test]
fn test_recursive_with_section_toggle() {
    let dir = setup_temp_dir_with_files(&[
        (
            "a.py",
            "before\n# toggle:start ID=feat\nhello\n# toggle:end ID=feat\nafter\n",
        ),
        (
            "b.py",
            "start\n# toggle:start ID=feat\nworld\n# toggle:end ID=feat\nend\n",
        ),
    ]);
    cmd()
        .args([dir.path().to_str().unwrap(), "-S", "feat", "-f", "on", "-R"])
        .assert()
        .success();
    let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
    let b = fs::read_to_string(dir.path().join("b.py")).unwrap();
    assert!(a.contains("# hello"), "a.py section should be commented");
    assert!(b.contains("# world"), "b.py section should be commented");
}

#[test]
fn test_recursive_with_multiple_languages() {
    let dir = setup_temp_dir_with_files(&[
        ("app.py", "print('hi')\n"),
        ("app.js", "console.log('hi');\n"),
        ("app.rs", "fn main() {}\n"),
    ]);
    cmd()
        .args([dir.path().to_str().unwrap(), "-l", "1:1", "-R"])
        .assert()
        .success();
    assert!(fs::read_to_string(dir.path().join("app.py"))
        .unwrap()
        .contains("# "));
    assert!(fs::read_to_string(dir.path().join("app.js"))
        .unwrap()
        .contains("// "));
    assert!(fs::read_to_string(dir.path().join("app.rs"))
        .unwrap()
        .contains("// "));
}

#[test]
fn test_directory_without_recursive_flag_errors() {
    let dir = setup_temp_dir_with_files(&[("test.py", "hello\n")]);
    cmd()
        .args([dir.path().to_str().unwrap(), "-l", "1:1"])
        .assert()
        .failure()
        .stderr(predicates::str::contains("use -R/--recursive"));
}

#[test]
fn test_recursive_with_dry_run() {
    let dir = setup_temp_dir_with_files(&[("a.py", "hello\n"), ("b.py", "world\n")]);
    cmd()
        .args([dir.path().to_str().unwrap(), "-l", "1:1", "-R", "--dry-run"])
        .assert()
        .success();
    // Files should not be modified
    assert_eq!(
        fs::read_to_string(dir.path().join("a.py")).unwrap(),
        "hello\n"
    );
    assert_eq!(
        fs::read_to_string(dir.path().join("b.py")).unwrap(),
        "world\n"
    );
}

#[test]
fn test_recursive_with_json_output() {
    let dir = setup_temp_dir_with_files(&[("a.py", "hello\n"), ("sub/b.js", "world\n")]);
    let output = cmd()
        .args([dir.path().to_str().unwrap(), "-l", "1:1", "-R", "--json"])
        .output()
        .unwrap();
    assert!(output.status.success());
    let json: Vec<Value> = serde_json::from_slice(&output.stdout).unwrap();
    assert!(
        json.len() >= 2,
        "JSON should have entries for each processed file"
    );
    assert!(json.iter().all(|e| e["success"] == true));
}

// ── Cross-file section discovery and toggling ──

#[test]
fn test_recursive_skips_files_without_matching_section() {
    let dir = setup_temp_dir_with_files(&[
        (
            "a.py",
            "before\n# toggle:start ID=feat1\nhello\n# toggle:end ID=feat1\nafter\n",
        ),
        ("b.py", "no sections here\n"),
        (
            "c.py",
            "# toggle:start ID=other\nstuff\n# toggle:end ID=other\n",
        ),
    ]);
    cmd()
        .args([
            dir.path().to_str().unwrap(),
            "-R",
            "-S",
            "feat1",
            "-f",
            "on",
        ])
        .assert()
        .success();
    let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
    assert!(a.contains("# hello"), "a.py should be toggled");
    let b = fs::read_to_string(dir.path().join("b.py")).unwrap();
    assert_eq!(b, "no sections here\n", "b.py should be unchanged");
    let c = fs::read_to_string(dir.path().join("c.py")).unwrap();
    assert!(
        c.contains("stuff"),
        "c.py should be unchanged (different section ID)"
    );
}

#[test]
fn test_recursive_force_applies_uniformly() {
    let dir = setup_temp_dir_with_files(&[
        (
            "a.py",
            "before\n# toggle:start ID=feat1\n# already_on\n# toggle:end ID=feat1\nafter\n",
        ),
        (
            "b.py",
            "top\n# toggle:start ID=feat1\n# also_on\n# toggle:end ID=feat1\nbottom\n",
        ),
    ]);
    // Force off: uncomment all
    cmd()
        .args([
            dir.path().to_str().unwrap(),
            "-R",
            "-S",
            "feat1",
            "-f",
            "off",
        ])
        .assert()
        .success();
    let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
    let b = fs::read_to_string(dir.path().join("b.py")).unwrap();
    assert!(
        a.contains("already_on") && !a.contains("# already_on"),
        "a.py should be uncommented"
    );
    assert!(
        b.contains("also_on") && !b.contains("# also_on"),
        "b.py should be uncommented"
    );
}

// ── --list-sections discovery mode ──

#[test]
fn test_list_sections_discovery() {
    let dir = setup_temp_dir_with_files(&[
        (
            "a.py",
            "# toggle:start ID=feat1 desc=\"Feature one\"\nhello\n# toggle:end ID=feat1\n",
        ),
        (
            "b.py",
            "# toggle:start ID=feat2\nworld\n# toggle:end ID=feat2\n",
        ),
        (
            "sub/c.py",
            "# toggle:start ID=feat1\nfoo\n# toggle:end ID=feat1\n",
        ),
    ]);
    let output = cmd()
        .args([dir.path().to_str().unwrap(), "-R", "--list-sections"])
        .output()
        .unwrap();
    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).unwrap();
    assert!(stdout.contains("feat1"), "should list feat1");
    assert!(stdout.contains("feat2"), "should list feat2");
    assert!(stdout.contains("Feature one"), "should show desc for feat1");
}

#[test]
fn test_list_sections_json() {
    let dir = setup_temp_dir_with_files(&[
        (
            "a.py",
            "# toggle:start ID=feat1 desc=\"Feature one\"\nhello\n# toggle:end ID=feat1\n",
        ),
        (
            "sub/b.py",
            "# toggle:start ID=feat1\nworld\n# toggle:end ID=feat1\n",
        ),
    ]);
    let output = cmd()
        .args([
            dir.path().to_str().unwrap(),
            "-R",
            "--list-sections",
            "--json",
        ])
        .output()
        .unwrap();
    assert!(output.status.success());
    let json: Vec<Value> =
        serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
    assert_eq!(json.len(), 1, "should have one section ID");
    assert_eq!(json[0]["id"], "feat1");
    assert_eq!(json[0]["desc"], "Feature one");
    let files = json[0]["files"].as_array().unwrap();
    assert_eq!(files.len(), 2, "feat1 should appear in 2 files");
    assert!(files[0]["file"].as_str().is_some());
    assert!(files[0]["start_line"].as_u64().is_some());
    assert!(files[0]["end_line"].as_u64().is_some());
}

#[test]
fn test_list_sections_conflicts_with_line_flag() {
    let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
    cmd()
        .args([
            dir.path().join("a.py").to_str().unwrap(),
            "--list-sections",
            "-l",
            "1:1",
        ])
        .assert()
        .failure()
        .stderr(predicates::str::contains(
            "--list-sections cannot be combined with --line",
        ));
}

#[test]
fn test_list_sections_conflicts_with_force_flag() {
    let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
    cmd()
        .args([
            dir.path().join("a.py").to_str().unwrap(),
            "--list-sections",
            "-f",
            "on",
        ])
        .assert()
        .failure()
        .stderr(predicates::str::contains(
            "--list-sections cannot be combined with --force",
        ));
}

// ── Cross-file JSON output ──

#[test]
fn test_cross_file_json_output() {
    let dir = setup_temp_dir_with_files(&[
        (
            "a.py",
            "# toggle:start ID=feat1 desc=\"My feature\"\nhello\n# toggle:end ID=feat1\n",
        ),
        (
            "sub/b.py",
            "# toggle:start ID=feat1\nworld\n# toggle:end ID=feat1\n",
        ),
    ]);
    let output = cmd()
        .args([
            dir.path().to_str().unwrap(),
            "-R",
            "-S",
            "feat1",
            "-f",
            "on",
            "--json",
        ])
        .output()
        .unwrap();
    assert!(output.status.success());
    let json: Vec<Value> =
        serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
    assert_eq!(json.len(), 2, "should have per-file entries");
    for entry in &json {
        assert_eq!(entry["action"], "toggle_section");
        assert_eq!(entry["success"], true);
        assert_eq!(entry["section_id"], "feat1");
        assert!(entry["lines_changed"].as_u64().unwrap() > 0);
    }
    // At least one entry should have desc
    let has_desc = json.iter().any(|e| e["desc"] == "My feature");
    assert!(has_desc, "at least one entry should have desc");
}

// ── Section desc= in verbose output ──

#[test]
fn test_section_desc_in_verbose_output() {
    let content = "# toggle:start ID=feat1 desc=\"Debug logging\"\nhello\n# toggle:end ID=feat1\n";
    let (_dir, path) = setup_temp_file(content, "test.py");
    let output = cmd()
        .args([path.to_str().unwrap(), "-S", "feat1", "-f", "on", "-v"])
        .output()
        .unwrap();
    assert!(output.status.success());
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(
        stderr.contains("Section desc: Debug logging"),
        "verbose should show desc"
    );
}

// ── Scan (--scan) ──

#[test]
fn test_scan_discovers_sections() {
    let dir = TempDir::new().unwrap();
    let content = "\
# toggle:start ID=debug desc=\"Debug output\"
# print('debug')
# toggle:end ID=debug

# toggle:start ID=feature
print('feature')
# toggle:end ID=feature
";
    fs::write(dir.path().join("app.py"), content).unwrap();

    let output = cmd()
        .args([dir.path().to_str().unwrap(), "--scan"])
        .output()
        .unwrap();
    assert!(output.status.success());

    let stdout = String::from_utf8(output.stdout).unwrap();
    assert!(stdout.contains("debug"), "should list debug section");
    assert!(stdout.contains("feature"), "should list feature section");
    assert!(stdout.contains("commented"), "should show commented state");
    assert!(
        stdout.contains("uncommented"),
        "should show uncommented state"
    );
}

#[test]
fn test_scan_json_output() {
    let dir = TempDir::new().unwrap();
    let content = "# toggle:start ID=test desc=\"A test\"\n# code\n# toggle:end ID=test\n";
    fs::write(dir.path().join("file.py"), content).unwrap();

    let output = cmd()
        .args([dir.path().to_str().unwrap(), "--scan", "--json"])
        .output()
        .unwrap();
    assert!(output.status.success());

    let json: Value = serde_json::from_slice(&output.stdout)
        .expect("scan --json should produce valid nested JSON");
    let sections = json["sections"].as_array().expect("sections array");
    assert_eq!(sections.len(), 1);
    assert_eq!(sections[0]["id"], "test");
    assert_eq!(sections[0]["type"], "solo");
    let files = sections[0]["files"].as_array().expect("files array");
    assert_eq!(files.len(), 1);
    assert_eq!(files[0]["state"], "commented");
    assert_eq!(files[0]["desc"], "A test");
    assert!(files[0]["start"].is_number());
    assert!(files[0]["end"].is_number());
}

#[test]
fn test_scan_empty_directory() {
    let dir = TempDir::new().unwrap();
    fs::write(dir.path().join("empty.py"), "print('no sections')\n").unwrap();

    let output = cmd()
        .args([dir.path().to_str().unwrap(), "--scan"])
        .output()
        .unwrap();
    assert!(output.status.success());
    let stdout = String::from_utf8(output.stdout).unwrap();
    assert!(stdout.contains("No toggle sections found"));
}

#[test]
fn test_scan_with_line_flag_errors() {
    let dir = TempDir::new().unwrap();
    fs::write(dir.path().join("test.py"), "x\n").unwrap();

    cmd()
        .args([dir.path().to_str().unwrap(), "--scan", "-l", "1:2"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("--scan cannot be combined"));
}

#[test]
fn test_scan_with_section_flag_succeeds_for_unknown_group() {
    let dir = TempDir::new().unwrap();
    fs::write(dir.path().join("test.py"), "x\n").unwrap();

    cmd()
        .args([dir.path().to_str().unwrap(), "--scan", "-S", "foo"])
        .assert()
        .success()
        .stdout(predicate::str::contains("No sections found for 'foo'"));
}

#[test]
fn test_scan_does_not_modify_files() {
    let dir = TempDir::new().unwrap();
    let content = "# toggle:start ID=sec\ncode\n# toggle:end ID=sec\n";
    let file = dir.path().join("test.py");
    fs::write(&file, content).unwrap();

    cmd()
        .args([dir.path().to_str().unwrap(), "--scan"])
        .assert()
        .success();

    let after = fs::read_to_string(&file).unwrap();
    assert_eq!(after, content, "scan should not modify files");
}

#[test]
fn test_scan_multiple_directories() {
    let dir = TempDir::new().unwrap();
    let pkg_a = dir.path().join("pkg_a");
    let pkg_b = dir.path().join("pkg_b");
    fs::create_dir(&pkg_a).unwrap();
    fs::create_dir(&pkg_b).unwrap();

    fs::write(
        pkg_a.join("mod.py"),
        "# toggle:start ID=alpha\n# x\n# toggle:end ID=alpha\n",
    )
    .unwrap();
    fs::write(
        pkg_b.join("mod.rs"),
        "// toggle:start ID=beta\n// y\n// toggle:end ID=beta\n",
    )
    .unwrap();

    let output = cmd()
        .args([pkg_a.to_str().unwrap(), pkg_b.to_str().unwrap(), "--scan"])
        .output()
        .unwrap();
    assert!(output.status.success());

    let stdout = String::from_utf8(output.stdout).unwrap();
    assert!(stdout.contains("alpha"), "should find alpha section");
    assert!(stdout.contains("beta"), "should find beta section");
}

// ── Atomic multi-file mode (Phase 4) ──

#[test]
fn test_atomic_happy_path_multi_file() {
    let dir = setup_temp_dir_with_files(&[("a.py", "hello\nworld\n"), ("b.py", "foo\nbar\n")]);
    cmd()
        .current_dir(dir.path())
        .args([dir.path().to_str().unwrap(), "-R", "-l", "1:1", "--atomic"])
        .assert()
        .success();
    let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
    let b = fs::read_to_string(dir.path().join("b.py")).unwrap();
    assert_eq!(a, "# hello\nworld\n", "a.py should be toggled");
    assert_eq!(b, "# foo\nbar\n", "b.py should be toggled");
    // No journal or lock files should remain
    assert!(
        !dir.path().join(".toggle-atomic.journal").exists(),
        "journal should be cleaned up"
    );
    assert!(
        !dir.path().join(".toggle-atomic.lock").exists(),
        "lock should be cleaned up"
    );
}

#[test]
fn test_atomic_no_changes_does_nothing() {
    let dir = setup_temp_dir_with_files(&[("a.py", "# hello\n")]);
    cmd()
        .current_dir(dir.path())
        .args([
            dir.path().join("a.py").to_str().unwrap(),
            "-l",
            "1:1",
            "-f",
            "on",
            "--atomic",
        ])
        .assert()
        .success();
    // Already commented, force on should still produce "# hello"
    let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
    assert_eq!(a, "# hello\n");
}

#[test]
fn test_atomic_implies_backup_by_default() {
    // This test verifies that --atomic creates backups (hard links) and removes
    // them on success. We verify indirectly: after a successful --atomic run,
    // no .toggle-atomic-backup files remain.
    let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
    cmd()
        .current_dir(dir.path())
        .args([
            dir.path().join("a.py").to_str().unwrap(),
            "-l",
            "1:1",
            "--atomic",
        ])
        .assert()
        .success();
    let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
    assert_eq!(a, "# hello\n");
    // Backup should have been cleaned up
    assert!(!dir.path().join("a.py.toggle-atomic-backup").exists());
}

#[test]
fn test_atomic_no_backup_flag() {
    let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
    let output = cmd()
        .current_dir(dir.path())
        .args([
            dir.path().join("a.py").to_str().unwrap(),
            "-l",
            "1:1",
            "--atomic",
            "--no-backup",
        ])
        .output()
        .unwrap();
    assert!(output.status.success());
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(
        stderr.contains("without backups"),
        "should warn about no backups"
    );
}

#[test]
fn test_atomic_with_dry_run_errors() {
    let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
    cmd()
        .current_dir(dir.path())
        .args([
            dir.path().join("a.py").to_str().unwrap(),
            "-l",
            "1:1",
            "--atomic",
            "--dry-run",
        ])
        .assert()
        .failure()
        .stderr(predicates::str::contains(
            "--atomic cannot be combined with --dry-run",
        ));
}

#[test]
fn test_no_backup_without_atomic_errors() {
    let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
    cmd()
        .current_dir(dir.path())
        .args([
            dir.path().join("a.py").to_str().unwrap(),
            "-l",
            "1:1",
            "--no-backup",
        ])
        .assert()
        .failure()
        .stderr(predicates::str::contains(
            "--no-backup is only valid with --atomic",
        ));
}

#[test]
fn test_recover_forward_without_recover_errors() {
    let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
    cmd()
        .current_dir(dir.path())
        .args([
            dir.path().join("a.py").to_str().unwrap(),
            "-l",
            "1:1",
            "--recover-forward",
        ])
        .assert()
        .failure()
        .stderr(predicates::str::contains(
            "--recover-forward requires --recover",
        ));
}

#[test]
fn test_atomic_section_toggle_multi_file() {
    let dir = setup_temp_dir_with_files(&[
        (
            "a.py",
            "before\n# toggle:start ID=feat1\nhello\n# toggle:end ID=feat1\nafter\n",
        ),
        (
            "b.py",
            "top\n# toggle:start ID=feat1\nworld\n# toggle:end ID=feat1\nbottom\n",
        ),
    ]);
    cmd()
        .current_dir(dir.path())
        .args([
            dir.path().to_str().unwrap(),
            "-R",
            "-S",
            "feat1",
            "-f",
            "on",
            "--atomic",
        ])
        .assert()
        .success();
    let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
    let b = fs::read_to_string(dir.path().join("b.py")).unwrap();
    assert!(a.contains("# hello"), "a.py should be commented");
    assert!(b.contains("# world"), "b.py should be commented");
}

#[test]
fn test_recover_no_journal_is_noop() {
    let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
    let output = cmd()
        .current_dir(dir.path())
        .args([dir.path().join("a.py").to_str().unwrap(), "--recover"])
        .output()
        .unwrap();
    assert!(output.status.success());
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(stderr.contains("No journal found"));
}

#[test]
fn test_recover_from_staged_journal() {
    // Manually create a journal in Staged state with temp files
    let dir = TempDir::new().unwrap();
    let target = dir.path().join("target.py");
    let temp = dir.path().join("temp_staged_file");
    fs::write(&target, "original\n").unwrap();
    fs::write(&temp, "modified\n").unwrap();

    let journal = toggle::journal::Journal::new(
        vec![toggle::journal::JournalEntry {
            target_path: target.clone(),
            temp_path: temp.clone(),
            backup_path: None,
            content_sha256: "xxx".to_string(),
            rename_completed: false,
        }],
        false,
    );
    let journal_path = dir.path().join(".toggle-atomic.journal");
    toggle::journal::persist_journal(&journal, &journal_path).unwrap();

    // Run --recover
    cmd()
        .current_dir(dir.path())
        .args([target.to_str().unwrap(), "--recover"])
        .assert()
        .success();

    // Temp file should be deleted, original untouched
    assert!(!temp.exists(), "temp file should be cleaned up");
    assert!(!journal_path.exists(), "journal should be deleted");
    assert_eq!(
        fs::read_to_string(&target).unwrap(),
        "original\n",
        "original should be untouched"
    );
}

#[test]
fn test_journal_blocks_new_operations() {
    // Create a leftover journal
    let dir = TempDir::new().unwrap();
    let target = dir.path().join("target.py");
    fs::write(&target, "hello\n").unwrap();
    let journal_path = dir.path().join(".toggle-atomic.journal");
    fs::write(&journal_path, "{}").unwrap(); // minimal journal

    cmd()
        .current_dir(dir.path())
        .args([target.to_str().unwrap(), "-l", "1:1"])
        .assert()
        .failure()
        .stderr(predicates::str::contains("previous atomic operation"));
}

#[test]
fn test_atomic_with_verbose() {
    let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
    let output = cmd()
        .current_dir(dir.path())
        .args([
            dir.path().join("a.py").to_str().unwrap(),
            "-l",
            "1:1",
            "--atomic",
            "-v",
        ])
        .output()
        .unwrap();
    assert!(output.status.success());
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(stderr.contains("Staging"));
    assert!(stderr.contains("Committing"));
    assert!(stderr.contains("successful"));
}

#[test]
fn test_atomic_single_file() {
    let dir = setup_temp_dir_with_files(&[("a.py", "x\ny\nz\n")]);
    cmd()
        .current_dir(dir.path())
        .args([
            dir.path().join("a.py").to_str().unwrap(),
            "-l",
            "2:2",
            "--atomic",
        ])
        .assert()
        .success();
    let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
    assert_eq!(a, "x\n# y\nz\n");
}

// ── Section variants (PRD §0.13) ──

fn copy_variants_fixture() -> (TempDir, std::path::PathBuf) {
    let dir = TempDir::new().unwrap();
    let dst = dir.path().join("variants.py");
    fs::copy("tests/fixtures/variants.py", &dst).unwrap();
    (dir, dst)
}

#[test]
fn variant_pair_flip() {
    let (_dir, path) = copy_variants_fixture();
    cmd()
        .args([path.to_str().unwrap(), "-S", "db"])
        .assert()
        .success();
    let content = fs::read_to_string(&path).unwrap();
    // sqlite block now commented, postgres now uncommented
    assert!(content.contains("# import sqlite3"), "after: {content}");
    assert!(content.contains("\nimport psycopg2"), "after: {content}");
}

#[test]
fn variant_activate_named() {
    let (_dir, path) = copy_variants_fixture();
    cmd()
        .args([path.to_str().unwrap(), "-S", "db:postgres"])
        .assert()
        .success();
    let content = fs::read_to_string(&path).unwrap();
    assert!(content.contains("\nimport psycopg2"), "after: {content}");
    assert!(content.contains("# import sqlite3"), "after: {content}");
}

#[test]
fn variant_three_errors_without_qualifier() {
    let (_dir, path) = copy_variants_fixture();
    let before = fs::read_to_string(&path).unwrap();
    cmd()
        .args([path.to_str().unwrap(), "-S", "cache"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("cache"))
        .stderr(predicate::str::contains("3 variants"));
    let after = fs::read_to_string(&path).unwrap();
    assert_eq!(before, after, "file modified despite error");
}

#[test]
fn variant_force_on_all_in_group() {
    let (_dir, path) = copy_variants_fixture();
    cmd()
        .args([path.to_str().unwrap(), "-S", "cache", "--force", "on"])
        .assert()
        .success();
    let content = fs::read_to_string(&path).unwrap();
    // redis was uncommented before; should now be commented
    assert!(content.contains("# import redis"), "after: {content}");
    // memcache was already commented; ensure not double-commented
    assert!(!content.contains("# # import memcache"), "after: {content}");
}

#[test]
fn variant_force_off_all_in_group() {
    let (_dir, path) = copy_variants_fixture();
    cmd()
        .args([path.to_str().unwrap(), "-S", "cache", "--force", "off"])
        .assert()
        .success();
    let content = fs::read_to_string(&path).unwrap();
    assert!(content.contains("\nimport memcache"), "after: {content}");
    assert!(content.contains("\ncache = {}"), "after: {content}");
}

#[test]
fn variant_solo_unchanged_behavior() {
    // -S debug (solo) should still flip the single section.
    let (_dir, path) = copy_variants_fixture();
    cmd()
        .args([path.to_str().unwrap(), "-S", "debug"])
        .assert()
        .success();
    let content = fs::read_to_string(&path).unwrap();
    assert!(
        content.contains("# print(\"debug enabled\")"),
        "after: {content}"
    );
}

// ── --pair validation flag (PRD §0.13.4) ──

#[test]
fn pair_succeeds_on_two_variant_group() {
    let (_dir, path) = copy_variants_fixture();
    cmd()
        .args([path.to_str().unwrap(), "-S", "db", "--pair"])
        .assert()
        .success();
}

#[test]
fn pair_errors_on_three_variant_group() {
    let (_dir, path) = copy_variants_fixture();
    let before = fs::read_to_string(&path).unwrap();
    cmd()
        .args([path.to_str().unwrap(), "-S", "cache", "--pair"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("--pair"))
        .stderr(predicate::str::contains("3"));
    let after = fs::read_to_string(&path).unwrap();
    assert_eq!(before, after, "file modified despite --pair failure");
}

#[test]
fn pair_errors_on_one_variant_group() {
    let (_dir, path) = copy_variants_fixture();
    cmd()
        .args([path.to_str().unwrap(), "-S", "debug", "--pair"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("--pair"));
}

#[test]
fn pair_without_section_errors() {
    let (_dir, path) = copy_variants_fixture();
    cmd()
        .args([path.to_str().unwrap(), "--pair"])
        .assert()
        .failure();
}

// ── --scan output (PRD §0.14) ──

#[test]
fn scan_table_shows_type_column() {
    let output = cmd()
        .args(["--scan", "tests/fixtures/variants.py"])
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("SECTION"), "stdout: {stdout}");
    assert!(stdout.contains("TYPE"), "stdout: {stdout}");
    assert!(stdout.contains("db:sqlite"), "stdout: {stdout}");
    assert!(stdout.contains("pair"), "stdout: {stdout}");
    assert!(stdout.contains("group"), "stdout: {stdout}");
    assert!(stdout.contains("solo"), "stdout: {stdout}");
}

#[test]
fn scan_recursive_emits_summary() {
    let dir = TempDir::new().unwrap();
    fs::copy("tests/fixtures/variants.py", dir.path().join("variants.py")).unwrap();

    let output = cmd()
        .args(["--scan", "-R", dir.path().to_str().unwrap()])
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("FILES"), "stdout: {stdout}");
    assert!(stdout.contains("VARIANTS"), "stdout: {stdout}");
    assert!(stdout.contains("db"), "stdout: {stdout}");
    assert!(stdout.contains("cache"), "stdout: {stdout}");
    // Per-group rows, not per-file rows
    assert!(!stdout.contains("variants.py"), "stdout: {stdout}");
}

#[test]
fn scan_detailed_view_for_group() {
    let dir = TempDir::new().unwrap();
    fs::copy("tests/fixtures/variants.py", dir.path().join("variants.py")).unwrap();

    let output = cmd()
        .args(["--scan", "-S", "cache", dir.path().to_str().unwrap()])
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("GROUP: cache"), "stdout: {stdout}");
    assert!(stdout.contains("group, 3 variants"), "stdout: {stdout}");
    assert!(stdout.contains("cache:redis"), "stdout: {stdout}");
    assert!(stdout.contains("cache:memcached"), "stdout: {stdout}");
    assert!(stdout.contains("cache:inmemory"), "stdout: {stdout}");
    assert!(stdout.contains("variants.py"), "stdout: {stdout}");
}

#[test]
fn scan_json_emits_nested_tree() {
    let dir = TempDir::new().unwrap();
    fs::copy("tests/fixtures/variants.py", dir.path().join("variants.py")).unwrap();

    let output = cmd()
        .args(["--scan", "--json", dir.path().to_str().unwrap()])
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
    let sections = v["sections"].as_array().expect("sections array");

    // Solo: debug
    let debug = sections
        .iter()
        .find(|e| e["id"] == "debug")
        .expect("debug entry");
    assert_eq!(debug["type"], "solo");
    assert!(debug["files"].as_array().unwrap().len() >= 1);

    // Pair: db
    let db = sections
        .iter()
        .find(|e| e["group"] == "db")
        .expect("db entry");
    assert_eq!(db["type"], "pair");
    let variants = db["variants"].as_array().unwrap();
    assert_eq!(variants.len(), 2);

    // Group: cache (3 variants)
    let cache = sections
        .iter()
        .find(|e| e["group"] == "cache")
        .expect("cache entry");
    assert_eq!(cache["type"], "group");
    assert_eq!(cache["variants"].as_array().unwrap().len(), 3);
}

#[test]
fn scan_detailed_view_for_specific_variant() {
    let dir = TempDir::new().unwrap();
    fs::copy("tests/fixtures/variants.py", dir.path().join("variants.py")).unwrap();

    let output = cmd()
        .args(["--scan", "-S", "db:postgres", dir.path().to_str().unwrap()])
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("db:postgres"), "stdout: {stdout}");
    // Should NOT include sibling variant
    assert!(!stdout.contains("db:sqlite"), "stdout: {stdout}");
}

#[test]
fn check_reports_pair_mismatch_with_pair_flag() {
    let dir = TempDir::new().unwrap();
    fs::copy("tests/fixtures/variants.py", dir.path().join("variants.py")).unwrap();

    let output = cmd()
        .args(["--scan", "--check", "--pair", dir.path().to_str().unwrap()])
        .output()
        .unwrap();
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("cache"), "stdout: {stdout}");
    assert!(
        stdout.contains("WARN") || stdout.contains("ERR"),
        "stdout: {stdout}"
    );
}

#[test]
fn check_clean_scan_reports_ok() {
    let dir = TempDir::new().unwrap();
    fs::write(
        dir.path().join("clean.py"),
        "# toggle:start ID=foo\nx = 1\n# toggle:end ID=foo\n",
    )
    .unwrap();

    let output = cmd()
        .args(["--scan", "--check", dir.path().to_str().unwrap()])
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("OK"), "stdout: {stdout}");
}

#[test]
fn check_without_scan_errors() {
    let dir = TempDir::new().unwrap();
    fs::write(dir.path().join("a.py"), "x\n").unwrap();
    cmd()
        .args(["--check", dir.path().to_str().unwrap()])
        .assert()
        .failure()
        .stderr(predicate::str::contains("--check requires --scan"));
}

#[test]
fn check_unclosed_marker_exits_nonzero() {
    let dir = TempDir::new().unwrap();
    fs::write(dir.path().join("bad.py"), "# toggle:start ID=foo\nx = 1\n").unwrap();
    let output = cmd()
        .args(["--scan", "--check", dir.path().to_str().unwrap()])
        .output()
        .unwrap();
    assert!(!output.status.success(), "expected failure");
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("unclosed"), "stdout: {stdout}");
}

#[test]
fn completions_bash_emits_completion_script() {
    let output = cmd().args(["--completions", "bash"]).output().unwrap();
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("_toggle()"), "stdout: {stdout}");
    assert!(stdout.contains("--scan"), "expected flag in completions");
}

#[test]
fn man_page_emits_roff() {
    let output = cmd().args(["--man"]).output().unwrap();
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains(".TH toggle"),
        "stdout head: {}",
        &stdout[..stdout.len().min(200)]
    );
    assert!(stdout.contains(".SH NAME"), "expected NAME section");
}

#[test]
fn no_paths_errors() {
    cmd()
        .assert()
        .failure()
        .stderr(predicate::str::contains("path is required"));
}
