use crate::case_norm::normalize_case;
use crate::classifier::{
    classify, end_block_keyword, end_statement_has_name, extract_scope_name, LineKind,
};
use crate::config::{Config, EndOfLine, KeywordCase};
use crate::keyword_norm::normalize_keywords;
use crate::reader::read_logical_lines;
use crate::scope::ScopeTracker;
use crate::whitespace::normalize_whitespace;

/// Ensure a space after `!` in regular comments (not directives, Doxygen, Fypp).
fn normalize_comment_space(comment: &str) -> String {
    let bytes = comment.as_bytes();
    if bytes.is_empty() || bytes[0] != b'!' {
        return comment.to_string();
    }
    if bytes.len() == 1 {
        return comment.to_string();
    }
    let second = bytes[1];
    if second == b' '
        || second == b'$'
        || second == b'<'
        || second == b'>'
        || second == b'!'
        || second == b'*'
        || second == b'@'
        || second == b'&'
    {
        return comment.to_string();
    }
    format!("! {}", &comment[1..])
}

/// Apply indentation to a line. Strips existing leading whitespace and adds
/// `depth * indent_width` spaces. Blank lines remain blank.
fn apply_indent(line: &str, depth: usize, indent_width: usize) -> String {
    let trimmed = line.trim_start();
    if trimmed.is_empty() {
        return String::new();
    }
    let indent = " ".repeat(depth * indent_width);
    format!("{}{}", indent, trimmed)
}

/// Check if a line is an `end subroutine` or `end function` (for procedure spacing).
fn is_end_procedure(line: &str) -> bool {
    let trimmed = line.trim();
    let lower = trimmed.to_ascii_lowercase();
    let re_end_proc = regex::Regex::new(r"(?i)^end\s+(subroutine|function)\b").unwrap();
    re_end_proc.is_match(&lower)
}

/// Check if a line starts a new procedure (subroutine or function).
fn is_procedure_start(line: &str) -> bool {
    let trimmed = line.trim();
    let lower = trimmed.to_ascii_lowercase();
    let re_proc = regex::Regex::new(
        r"(?i)^(?:(?:pure|elemental|impure|recursive|module|integer|real|double\s+precision|complex|character|logical|type\s*\([^)]*\))\s+)*(subroutine|function)\b"
    ).unwrap();
    re_proc.is_match(&lower)
}

/// Format a Fortran source string using default config.
pub fn format(source: &str) -> String {
    format_with_config(source, &Config::default(), None)
}

/// Format with an optional line range (1-based, inclusive).
pub fn format_with_range(source: &str, range: Option<(usize, usize)>) -> String {
    format_with_config(source, &Config::default(), range)
}

/// Format with full config and optional range.
pub fn format_with_config(source: &str, config: &Config, range: Option<(usize, usize)>) -> String {
    // Remember the original line-ending style for EndOfLine::Preserve
    // (the source is normalized to \n for parsing below).
    let source_had_crlf = source.contains("\r\n");
    // Normalize line endings before parsing (ensures reader sees consistent \n)
    let normalized_source;
    let src = if source.contains('\r') {
        normalized_source = source.replace("\r\n", "\n").replace('\r', "\n");
        &normalized_source
    } else {
        source
    };
    let logical_lines = read_logical_lines(src);
    let mut tracker = ScopeTracker::new();
    let mut output_lines: Vec<String> = Vec::new();
    // Output index intervals emitted verbatim for out-of-range logical lines
    // (range mode): post-passes must not touch them.
    let mut protected_ranges: Vec<(usize, usize)> = Vec::new();
    let mut consecutive_blanks: usize = 0;
    let mut skip_next_blank = false;
    // Track whether we just emitted an end procedure line (for blank line insertion).
    let mut just_ended_procedure = false;
    // Track if we're inside a procedure body (for collapsing blanks to 1).
    let mut procedure_depth: Vec<usize> = Vec::new();

    let ll_count = logical_lines.len();
    let mut idx = 0;
    let mut formatting_disabled = false;

    while idx < ll_count {
        let ll = &logical_lines[idx];
        let kind = classify(&ll.joined);

        // Check for ! ffmt off / ! ffmt on markers
        {
            let trimmed_lower = ll.joined.trim().to_ascii_lowercase();
            if trimmed_lower == "! ffmt off" || trimmed_lower == "! ffmt: off" {
                formatting_disabled = true;
                for raw_line in &ll.raw_lines {
                    output_lines.push(raw_line.trim_end().to_string());
                }
                idx += 1;
                continue;
            }
            if trimmed_lower == "! ffmt on" || trimmed_lower == "! ffmt: on" {
                formatting_disabled = false;
                for raw_line in &ll.raw_lines {
                    output_lines.push(raw_line.trim_end().to_string());
                }
                idx += 1;
                continue;
            }
        }

        // While formatting is disabled, pass lines through verbatim — but
        // keep the scope tracker in sync, so indentation and named ends
        // AFTER the region are still correct.
        if formatting_disabled {
            let scope_name = if kind == LineKind::FortranBlockOpen {
                extract_scope_name(&ll.joined)
            } else {
                None
            };
            let _ = tracker.process_with_name(kind, scope_name);
            for raw_line in &ll.raw_lines {
                output_lines.push(raw_line.trim_end().to_string());
            }
            idx += 1;
            continue;
        }

        // Check range
        let last_raw_line = ll.line_number + ll.raw_lines.len() - 1;
        let in_range = match range {
            None => true,
            Some((start, end)) => ll.line_number <= end && last_raw_line >= start,
        };

        // --- Blank line handling ---
        if kind == LineKind::Blank {
            if in_range && skip_next_blank {
                skip_next_blank = false;
                idx += 1;
                continue;
            }
            if in_range {
                consecutive_blanks += 1;
                // Inside procedures: collapse 2+ blanks to 1
                // Outside procedures (but in contains): collapse 2+ to 1
                // General: collapse 3+ to 2
                let max_blanks = if !procedure_depth.is_empty() || tracker.in_contains() {
                    1
                } else {
                    2
                };
                if consecutive_blanks > max_blanks {
                    idx += 1;
                    continue;
                }
            }
            let _ = tracker.process(kind);
            if !in_range {
                protected_ranges.push((output_lines.len(), output_lines.len() + 1));
            }
            output_lines.push(String::new());
            idx += 1;
            continue;
        }

        // If we just ended a procedure and this non-blank line is not another end/contains,
        // we may need to ensure exactly one blank line before it.
        if just_ended_procedure && in_range && tracker.in_contains() {
            let is_end_or_contains =
                kind == LineKind::FortranBlockClose || kind == LineKind::FortranContains;
            if !is_end_or_contains {
                // This should be the start of a new procedure. Ensure exactly one blank line.
                // Remove excess blank lines (already handled by collapse above).
                // If there are zero blank lines, insert one.
                if consecutive_blanks == 0 {
                    output_lines.push(String::new());
                }
            }
        }

        consecutive_blanks = 0;
        skip_next_blank = false;
        just_ended_procedure = false;

        // --- Track procedure entry/exit ---
        if kind == LineKind::FortranBlockOpen
            && tracker.in_contains()
            && is_procedure_start(&ll.joined)
        {
            procedure_depth.push(tracker.current_depth());
        }

        // --- Scope tracking (always runs) ---
        // Extract scope name for block openers
        let scope_name = if kind == LineKind::FortranBlockOpen {
            extract_scope_name(&ll.joined)
        } else {
            None
        };

        let mut depth = tracker.process_with_name(kind, scope_name);

        // Track procedure exit
        if kind == LineKind::FortranBlockClose {
            if let Some(&proc_depth) = procedure_depth.last() {
                if tracker.current_depth() <= proc_depth {
                    procedure_depth.pop();
                }
            }
        }

        if !config.indent_fypp
            && matches!(
                kind,
                LineKind::FyppBlockOpen
                    | LineKind::FyppBlockClose
                    | LineKind::FyppContinuation
                    | LineKind::FyppStatement
                    | LineKind::InlineFypp
            )
        {
            depth = 0;
        }

        // Check for acc loop directive
        if kind == LineKind::Directive {
            let content_lower = ll.joined.trim().to_ascii_lowercase();
            if content_lower.contains("!$acc loop") || content_lower.contains("!$acc parallel loop")
            {
                skip_next_blank = true;
            }
        }

        // --- If outside range, emit unchanged ---
        if !in_range {
            let start = output_lines.len();
            for raw_line in &ll.raw_lines {
                output_lines.push(raw_line.trim_end().to_string());
            }
            protected_ranges.push((start, output_lines.len()));
            idx += 1;
            continue;
        }

        // --- Emit each raw line with formatting ---
        // Track the indent delta from the first line so continuation lines
        // can be shifted proportionally.

        for (raw_idx, raw_line) in ll.raw_lines.iter().enumerate() {
            let trimmed = raw_line.trim_end();

            match kind {
                LineKind::Comment => {
                    let content = trimmed.trim_start();
                    let content = if config.unicode_to_ascii {
                        crate::unicode::replace_unicode(content)
                    } else {
                        content.to_string()
                    };
                    let content = if config.space_after_comment.is_enabled() {
                        normalize_comment_space(&content)
                    } else {
                        content
                    };
                    if content.starts_with("!!") {
                        // Doxygen continuation: apply proper indentation
                        let indented = apply_indent(&content, depth, config.indent_width);
                        output_lines.push(indented);
                    } else if content.starts_with("!>")
                        && config.rewrap_comments.is_enabled()
                        && range.is_none()
                    {
                        // Doxygen start: collect any following !! continuation lines,
                        // join the text, and re-wrap as a single block. Joining is
                        // a rewrap (gated on rewrap-comments) and consumes the
                        // following lines, so it is disabled in range mode — it
                        // would rewrite lines outside the requested range.
                        let marker_text = extract_comment_text(&content, "!>");
                        let mut full_text = marker_text.to_string();

                        // Look ahead for !! continuation lines
                        while idx + 1 < ll_count {
                            let next_ll = &logical_lines[idx + 1];
                            let next_kind = classify(&next_ll.joined);
                            if next_kind != LineKind::Comment {
                                break;
                            }
                            let next_content = next_ll.joined.trim().trim_start();
                            if !next_content.starts_with("!!") {
                                break;
                            }
                            let cont_text = extract_comment_text(next_content, "!!");
                            // Don't join if continuation starts with @
                            // (separate Doxygen command) or is blank
                            if cont_text.starts_with('@') || cont_text.trim().is_empty() {
                                break;
                            }
                            let cont_text = if config.unicode_to_ascii {
                                crate::unicode::replace_unicode(cont_text)
                            } else {
                                cont_text.to_string()
                            };
                            full_text.push(' ');
                            full_text.push_str(&cont_text);
                            idx += 1;
                            // Process scope for skipped lines
                            let _ = tracker.process(next_kind);
                        }

                        let indent_str = " ".repeat(depth * config.indent_width);
                        // Normalize internal whitespace (collapse multiple spaces)
                        let full_trimmed: String = full_text
                            .split_whitespace()
                            .collect::<Vec<&str>>()
                            .join(" ");
                        let reconstructed = if full_trimmed.is_empty() {
                            format!("{}!>", indent_str)
                        } else {
                            format!("{}!> {}", indent_str, full_trimmed)
                        };
                        let wrapped = if config.rewrap_comments.is_enabled() {
                            wrap_comment(
                                &reconstructed,
                                config.line_length,
                                depth,
                                config.indent_width,
                            )
                        } else {
                            vec![reconstructed.clone()]
                        };
                        output_lines.extend(wrapped);
                    } else {
                        let indented = apply_indent(&content, depth, config.indent_width);
                        let wrapped = if config.rewrap_comments.is_enabled() {
                            wrap_comment(&indented, config.line_length, depth, config.indent_width)
                        } else {
                            vec![indented.clone()]
                        };
                        output_lines.extend(wrapped);
                    }
                }
                LineKind::Blank => unreachable!(),
                LineKind::PreprocessorDirective
                | LineKind::PreprocessorContinuation
                | LineKind::PreprocessorClose => {
                    output_lines.push(trimmed.to_string());
                }
                LineKind::FyppStatement
                | LineKind::FyppBlockOpen
                | LineKind::FyppBlockClose
                | LineKind::FyppContinuation => {
                    if raw_idx == 0 {
                        output_lines.push(apply_indent(
                            trimmed.trim_start(),
                            depth,
                            config.indent_width,
                        ));
                    } else {
                        // Fypp continuation lines: preserve entirely
                        output_lines.push(trimmed.to_string());
                    }
                }
                _ => {
                    if kind == LineKind::InlineFypp {
                        // InlineFypp ($:, @:): only Fypp-specific cleanups.
                        // Do NOT run whitespace normalization — Fypp lines contain
                        // Python code and Fypp quoting ({' ... '}) that the Fortran
                        // normalizer can't parse correctly.
                        // For multi-line, use the joined line (reader normalizes spacing).
                        let source = if ll.raw_lines.len() > 1 {
                            emit_hoisted_comments(
                                &ll.hoisted_comments,
                                depth,
                                config,
                                &mut output_lines,
                            );
                            &ll.joined
                        } else {
                            trimmed
                        };
                        let mut processed = source.to_string();
                        if config.normalize_keywords.is_enabled() {
                            processed = normalize_keywords(&processed);
                        }
                        match config.keyword_case {
                            KeywordCase::Lower => processed = normalize_case(&processed),
                            KeywordCase::Upper => processed = normalize_case_upper(&processed),
                            KeywordCase::Preserve => {}
                        }
                        if config.fypp_list_commas.is_enabled() {
                            processed = normalize_fypp_lists(&processed);
                        }
                        processed = remove_fypp_macro_paren_space(&processed);
                        let formatted = apply_indent(processed.trim(), depth, config.indent_width);
                        // Rewrap long Fypp lines at top-level argument commas
                        if config.rewrap_code.is_enabled() && formatted.len() > config.line_length {
                            let wrapped = rewrap_fypp_line(&formatted, config.line_length);
                            output_lines.extend(wrapped);
                        } else {
                            output_lines.push(formatted);
                        }
                        if ll.raw_lines.len() > 1 {
                            break; // Skip remaining raw lines
                        }
                    } else if ll.raw_lines.len() == 1 {
                        // Single-line: full normalization + rewrap if over limit
                        let processed = process_line(trimmed, config);
                        let mut formatted =
                            apply_indent(processed.trim(), depth, config.indent_width);

                        if kind == LineKind::FortranBlockClose && config.named_ends.is_enabled() {
                            formatted = maybe_add_end_name(&formatted, &tracker, config);
                        }

                        // Directives (!$acc/!$omp/!DIR$) are never rewrapped:
                        // breaking one without its sentinel on the
                        // continuation line silently disables it.
                        let wrapped =
                            if config.rewrap_code.is_enabled() && kind != LineKind::Directive {
                                rewrap_line(&formatted, config.line_length, config.indent_width)
                            } else {
                                vec![formatted.clone()]
                            };
                        output_lines.extend(wrapped);
                    } else if raw_idx == 0 {
                        // Check if the continuation was interrupted by a preprocessor
                        // directive (reader stopped at #ifdef/#ifndef). If so, don't
                        // unravel — preserve original structure to avoid breaking the
                        // Fortran statement that spans the preprocessor block.
                        let next_is_cpp = idx + 1 < ll_count && {
                            let next_kind = classify(&logical_lines[idx + 1].joined);
                            matches!(
                                next_kind,
                                LineKind::PreprocessorDirective
                                    | LineKind::PreprocessorContinuation
                                    | LineKind::PreprocessorClose
                                    | LineKind::FyppBlockOpen
                                    | LineKind::FyppBlockClose
                                    | LineKind::FyppContinuation
                                    | LineKind::FyppStatement
                            )
                        };

                        // Also check if the last raw line ends with & (meaning the
                        // continuation was genuinely cut short by a # directive)
                        let last_raw = ll.raw_lines.last().map(|s| s.trim_end()).unwrap_or("");
                        let continuation_interrupted = next_is_cpp && last_raw.ends_with('&');

                        if continuation_interrupted || ll.preserve {
                            // Preserve original structure — emit each raw line
                            // with keyword + case normalization only. Comment
                            // and `!$` directive lines are kept verbatim:
                            // normalization must not touch them.
                            for (ri, rl) in ll.raw_lines.iter().enumerate() {
                                let t = rl.trim_end();
                                if t.trim_start().starts_with('!') {
                                    output_lines.push(t.to_string());
                                    continue;
                                }
                                let mut processed = t.to_string();
                                if config.normalize_keywords.is_enabled() {
                                    processed = normalize_keywords(&processed);
                                }
                                match config.keyword_case {
                                    KeywordCase::Lower => processed = normalize_case(&processed),
                                    KeywordCase::Upper => {
                                        processed = normalize_case_upper(&processed)
                                    }
                                    KeywordCase::Preserve => {}
                                }
                                if ri == 0 {
                                    output_lines.push(apply_indent(
                                        processed.trim(),
                                        depth,
                                        config.indent_width,
                                    ));
                                } else {
                                    output_lines.push(processed);
                                }
                            }
                            break;
                        }

                        // Comments found inside the continuation are hoisted
                        // above the statement: re-emitting from the joined
                        // text would lose them, and leaving them between
                        // continuation lines is rejected by some compilers.
                        emit_hoisted_comments(
                            &ll.hoisted_comments,
                            depth,
                            config,
                            &mut output_lines,
                        );

                        // Normal multi-line: unravel joined line, normalize, rewrap
                        let processed = process_line(&ll.joined, config);
                        let formatted = apply_indent(processed.trim(), depth, config.indent_width);

                        let formatted = if kind == LineKind::FortranBlockClose
                            && config.named_ends.is_enabled()
                        {
                            maybe_add_end_name(&formatted, &tracker, config)
                        } else {
                            formatted
                        };

                        let wrapped = if config.rewrap_code.is_enabled() {
                            rewrap_line(&formatted, config.line_length, config.indent_width)
                        } else {
                            vec![formatted.clone()]
                        };
                        output_lines.extend(wrapped);
                        break; // Skip remaining raw lines
                    } else {
                        unreachable!("raw_idx > 0 not reached after break");
                    }
                }
            }
        }

        // After emitting an end subroutine/function, mark for blank line insertion
        if kind == LineKind::FortranBlockClose {
            if let Some(last_line) = output_lines.last() {
                if is_end_procedure(last_line) {
                    just_ended_procedure = true;
                }
            }
        }

        idx += 1;
    }

    // Strip trailing blank lines (but never protected out-of-range ones)
    while output_lines.last().is_some_and(|l| l.is_empty())
        && !protected_ranges
            .iter()
            .any(|&(s, e)| s < output_lines.len() && output_lines.len() - 1 < e)
    {
        output_lines.pop();
    }

    // Apply post-processing passes, respecting ! ffmt off/on suppression
    // regions and (in range mode) the protected out-of-range segments.
    let protected = std::cell::RefCell::new(protected_ranges);
    let apply = |lines: Vec<String>, f: &dyn Fn(&[String]) -> Vec<String>| -> Vec<String> {
        let mut prot = protected.borrow_mut();
        if prot.is_empty() {
            return apply_respecting_suppression(lines, f);
        }
        let mut sorted: Vec<(usize, usize)> = prot.clone();
        sorted.sort_unstable();
        let mut out: Vec<String> = Vec::new();
        let mut new_prot: Vec<(usize, usize)> = Vec::new();
        let mut pos = 0usize;
        for &(seg_s, seg_e) in &sorted {
            if pos < seg_s {
                out.extend(apply_respecting_suppression(lines[pos..seg_s].to_vec(), f));
            }
            let ns = out.len();
            out.extend_from_slice(&lines[seg_s..seg_e]);
            new_prot.push((ns, out.len()));
            pos = seg_e;
        }
        if pos < lines.len() {
            out.extend(apply_respecting_suppression(lines[pos..].to_vec(), f));
        }
        *prot = new_prot;
        out
    };

    // Split semicolon-separated statements (early, before alignment)
    if config.split_statements.is_enabled() {
        let iw = config.indent_width;
        output_lines = apply(output_lines, &|lines| split_multi_statements(lines, iw));
    }

    // Reformat use statements (one import per line)
    if config.use_formatting.is_enabled() {
        let iw = config.indent_width;
        output_lines = apply(output_lines, &|lines| reformat_use_statements(lines, iw));
    }

    // Ensure blank line before and after `implicit none`
    output_lines = apply(output_lines, &ensure_implicit_none_spacing);

    // Remove blank lines immediately before block closers/continuations
    output_lines = apply(output_lines, &remove_blanks_before_closers);

    // Ensure blank lines after major block closers (before Doxygen, before end module, etc.)
    output_lines = apply(output_lines, &ensure_blank_after_end);

    // Enforce :: separator BEFORE the declaration/code blank-line pass, so
    // `integer i` is already `integer :: i` when declarations are detected
    // (running it later made the blank appear only on the second run).
    if config.enforce_double_colon.is_enabled() {
        output_lines = apply(output_lines, &enforce_double_colon);
    }

    // Ensure blank line between declaration block and executable code
    output_lines = apply(output_lines, &separate_declarations_from_code);

    // Join consecutive short ! comment lines that fit on one line
    if config.rewrap_comments.is_enabled() {
        let ll = config.line_length;
        output_lines = apply(output_lines, &|lines| join_short_comments(lines, ll));
    }

    // Re-wrap !< inline Doxygen comments with !! continuations (before compaction/alignment)
    if config.rewrap_comments.is_enabled() {
        let ll = config.line_length;
        output_lines = apply(output_lines, &|lines| rewrap_inline_doxygen(lines, ll));
    }
    // Compact use statements (after !< rewrap, since rewrap may consume !! lines)
    if config.compact_use.is_enabled() {
        output_lines = apply(output_lines, &compact_use_statements);
    }

    // Rewrap lines that exceeded line_length after !< comment removal
    if config.rewrap_code.is_enabled() {
        let ll = config.line_length;
        let iw = config.indent_width;
        output_lines = apply(output_lines, &|lines: &[String]| {
            let mut final_lines: Vec<String> = Vec::new();
            for line in lines {
                if line.len() > ll
                    && find_inline_doxygen_in_line(line).is_none()
                    && !line.trim_end().ends_with('&')
                    && !line.trim_start().starts_with("& ")
                {
                    final_lines.extend(rewrap_line(line, ll, iw));
                } else {
                    final_lines.push(line.clone());
                }
            }
            final_lines
        });
    }

    // Align :: in consecutive declaration lines
    if config.align_declarations {
        let cd = config.compact_declarations.is_enabled();
        let ac = config.align_comments;
        let ll = config.line_length;
        output_lines = apply(output_lines, &|lines| {
            crate::align::align_declarations(lines, cd, ac, ll)
        });
    }

    // Ensure at least 2 spaces before inline comments (Fortitude S102)
    {
        let ll = config.line_length;
        output_lines = apply(output_lines, &|lines| {
            ensure_two_spaces_before_inline_comment(lines, ll)
        });
    }

    // Strip trailing semicolons (Fortitude S081)
    output_lines = apply(output_lines, &strip_trailing_semicolons);

    // Collapse consecutive blank lines that may result from semicolon stripping
    output_lines = apply(output_lines, &|lines: &[String]| {
        let mut collapsed = Vec::with_capacity(lines.len());
        let mut prev_blank = false;
        for line in lines {
            let is_blank = line.trim().is_empty();
            if is_blank && prev_blank {
                continue;
            }
            prev_blank = is_blank;
            collapsed.push(line.clone());
        }
        collapsed
    });

    // Modernize legacy relational operators (.eq. -> ==, etc.)
    if config.modernize_operators.is_enabled() {
        output_lines = apply(output_lines, &modernize_relational_operators);
    }

    // Align = in consecutive assignment statements
    if config.align_assignments.is_enabled() {
        let ll = config.line_length;
        output_lines = apply(output_lines, &|lines| align_assignments(lines, ll));
    }

    // Align trailing & continuation markers at column limit
    if config.align_ampersand.is_enabled() {
        let ll = config.line_length;
        output_lines = apply(output_lines, &|lines| align_ampersands(lines, ll));
    }

    let mut result = output_lines.join("\n");
    if !result.is_empty() && !result.ends_with('\n') {
        result.push('\n');
    }

    // Apply EOL normalization
    match config.end_of_line {
        EndOfLine::Lf => {
            result = result.replace("\r\n", "\n").replace('\r', "\n");
        }
        EndOfLine::Crlf => {
            // First normalize to LF, then convert to CRLF
            result = result.replace("\r\n", "\n").replace('\r', "\n");
            result = result.replace('\n', "\r\n");
        }
        EndOfLine::Preserve => {
            // The source was normalized to LF for parsing; restore the
            // original ending style.
            if source_had_crlf {
                result = result.replace("\r\n", "\n").replace('\r', "\n");
                result = result.replace('\n', "\r\n");
            }
        }
    }

    result
}

/// Check if a line is a `! ffmt off` or `! ffmt on` marker.
/// Returns Some(true) for off, Some(false) for on, None otherwise.
/// Emit comments hoisted out of a `&` continuation as full-line comments
/// above the statement, at the statement's indent.
fn emit_hoisted_comments(
    comments: &[String],
    depth: usize,
    config: &Config,
    output_lines: &mut Vec<String>,
) {
    for comment in comments {
        let c = if config.unicode_to_ascii {
            crate::unicode::replace_unicode(comment)
        } else {
            comment.clone()
        };
        let c = if config.space_after_comment.is_enabled() {
            normalize_comment_space(&c)
        } else {
            c
        };
        output_lines.push(apply_indent(&c, depth, config.indent_width));
    }
}

fn is_ffmt_marker(line: &str) -> Option<bool> {
    let t = line.trim().to_ascii_lowercase();
    if t == "! ffmt off" || t == "! ffmt: off" {
        Some(true) // off
    } else if t == "! ffmt on" || t == "! ffmt: on" {
        Some(false) // on (i.e., not off)
    } else {
        None
    }
}

/// Apply a post-processing transform function while respecting `! ffmt off`/`! ffmt on`
/// suppression regions. Lines within suppressed regions are passed through verbatim.
fn apply_respecting_suppression(
    lines: Vec<String>,
    transform: &dyn Fn(&[String]) -> Vec<String>,
) -> Vec<String> {
    // Quick check: if no suppression markers exist, apply transform directly
    let has_markers = lines.iter().any(|l| is_ffmt_marker(l).is_some());
    if !has_markers {
        return transform(&lines);
    }

    // Split into segments: (lines, is_suppressed)
    let mut result: Vec<String> = Vec::new();
    let mut current_segment: Vec<String> = Vec::new();
    let mut suppressed = false;

    for line in &lines {
        if let Some(is_off) = is_ffmt_marker(line) {
            // Flush the current segment
            if !current_segment.is_empty() {
                if suppressed {
                    result.append(&mut current_segment);
                } else {
                    result.extend(transform(&current_segment));
                    current_segment.clear();
                }
            }
            // Emit the marker line itself verbatim
            result.push(line.clone());
            suppressed = is_off;
        } else {
            current_segment.push(line.clone());
        }
    }

    // Flush final segment
    if !current_segment.is_empty() {
        if suppressed {
            result.extend(current_segment);
        } else {
            result.extend(transform(&current_segment));
        }
    }

    result
}

/// Add the scope name to a bare `end subroutine/function/module/program/type` line.
fn maybe_add_end_name(line: &str, tracker: &ScopeTracker, config: &Config) -> String {
    // Only apply to named block types, not end if/do/select etc.
    if end_statement_has_name(line) {
        return line.to_string();
    }

    // Check if this is a nameable end statement
    if let Some(_keyword) = end_block_keyword(line) {
        if let Some(name) = tracker.last_closed_name() {
            // Apply case normalization to the name
            let final_name = match config.keyword_case {
                KeywordCase::Lower => name.to_ascii_lowercase(),
                KeywordCase::Upper => name.to_ascii_uppercase(),
                KeywordCase::Preserve => name.to_string(),
            };
            // Rebuild the line: preserve indentation, add name
            let trimmed = line.trim();
            let indent = &line[..line.len() - line.trim_start().len()];
            // Strip any trailing comment from the end statement
            let comment_start = trimmed.find('!');
            let (stmt_part, comment_part) = if let Some(pos) = comment_start {
                (trimmed[..pos].trim_end(), Some(&trimmed[pos..]))
            } else {
                (trimmed, None)
            };
            let mut result = format!("{}{} {}", indent, stmt_part, final_name);
            if let Some(comment) = comment_part {
                result = format!("{} {}", result, comment);
            }
            return result;
        }
    }

    line.to_string()
}

/// Process a single Fortran line: keyword norm -> whitespace norm -> case norm.
fn process_line(line: &str, config: &Config) -> String {
    let mut result = line.to_string();

    if config.normalize_keywords.is_enabled() {
        result = normalize_keywords(&result);
    }

    result = normalize_whitespace(&result, &config.whitespace);

    if config.keyword_paren_space.is_enabled() {
        result = crate::whitespace::add_keyword_paren_spaces(&result);
    }
    result = crate::whitespace::normalize_intent_paren(&result);
    if config.collapse_double_spaces {
        result = crate::whitespace::collapse_double_spaces(&result);
    }

    match config.keyword_case {
        KeywordCase::Lower => result = normalize_case(&result),
        KeywordCase::Upper => result = normalize_case_upper(&result),
        KeywordCase::Preserve => {}
    }

    if config.unicode_to_ascii {
        // Standalone comments are handled in the Comment branch; inline
        // trailing comments are converted here (code text is left alone —
        // unicode in code is the programmer's business).
        let (code, comment) = split_trailing_comment(&result);
        if !comment.is_empty() {
            result = format!("{}{}", code, crate::unicode::replace_unicode(comment));
        }
    }

    result
}

/// Count leading spaces in a string.
fn leading_spaces(s: &str) -> usize {
    s.len() - s.trim_start().len()
}

/// Uppercase Fortran keywords (inverse of normalize_case).
use crate::case_norm::normalize_case_upper;

/// Split a Doxygen comment line that has multiple @ commands into separate lines.
/// E.g., `!> @file @brief Foo` → [`!> @file`, `!! @brief Foo`]
/// Returns the original line as a single-element Vec if no splitting needed.
fn split_doxygen_commands(line: &str) -> Vec<String> {
    let trimmed = line.trim_start();
    if !trimmed.starts_with("!>") && !trimmed.starts_with("!!") {
        return vec![line.to_string()];
    }

    let indent = leading_spaces(line);
    let prefix = " ".repeat(indent);
    let marker = if trimmed.starts_with("!>") {
        "!>"
    } else {
        "!!"
    };
    let text = extract_comment_text(trimmed, marker);

    // Find @ commands in the text. Only a KNOWN doxygen command preceded by
    // whitespace counts — `bob@example.com` is text, not a command.
    const DOXYGEN_COMMANDS: &[&str] = &[
        "param",
        "return",
        "returns",
        "retval",
        "brief",
        "details",
        "author",
        "authors",
        "date",
        "note",
        "warning",
        "attention",
        "see",
        "sa",
        "ref",
        "todo",
        "bug",
        "deprecated",
        "code",
        "endcode",
        "example",
        "file",
        "ingroup",
        "defgroup",
        "addtogroup",
        "version",
        "copyright",
        "remark",
        "remarks",
        "par",
        "pre",
        "post",
        "throws",
        "exception",
        "tparam",
        "since",
        "test",
        "var",
        "fn",
        "name",
        "mainpage",
        "section",
        "subsection",
        "page",
        "anchor",
        "cite",
        "copydoc",
        "internal",
    ];
    let mut at_positions: Vec<usize> = Vec::new();
    let bytes = text.as_bytes();
    for i in 1..bytes.len() {
        if bytes[i] != b'@' || !bytes[i - 1].is_ascii_whitespace() {
            continue;
        }
        let word: String = text[i + 1..]
            .chars()
            .take_while(|c| c.is_ascii_alphabetic())
            .collect();
        if DOXYGEN_COMMANDS.contains(&word.to_ascii_lowercase().as_str()) {
            at_positions.push(i);
        }
    }

    if at_positions.is_empty() {
        return vec![line.to_string()];
    }

    // Split at each @ position
    let mut result = Vec::new();
    let mut prev = 0;
    let cont_marker = if marker == "!>" { "!!" } else { marker };

    for (idx, &pos) in at_positions.iter().enumerate() {
        let chunk = text[prev..pos].trim();
        if !chunk.is_empty() {
            let m = if idx == 0 { marker } else { cont_marker };
            result.push(format!("{}{} {}", prefix, m, chunk));
        }
        prev = pos;
    }
    // Last chunk
    let chunk = text[prev..].trim();
    if !chunk.is_empty() {
        let m = if result.is_empty() {
            marker
        } else {
            cont_marker
        };
        result.push(format!("{}{} {}", prefix, m, chunk));
    }

    if result.is_empty() {
        vec![line.to_string()]
    } else {
        result
    }
}

/// Extract the text content from a comment line, stripping the marker and leading space.
fn extract_comment_text<'a>(line: &'a str, marker: &str) -> &'a str {
    let after_marker = &line[marker.len()..];
    after_marker.strip_prefix(' ').unwrap_or(after_marker)
}

/// Wrap a long comment line at word boundaries.
/// Preserves the comment marker style (!, !>, !<, etc.)
fn wrap_comment(line: &str, max_length: usize, _depth: usize, _indent_width: usize) -> Vec<String> {
    // First: if a Doxygen line has multiple @ commands, split them
    let split_at_cmds = split_doxygen_commands(line);
    if split_at_cmds.len() > 1 {
        let mut result = Vec::new();
        for sub in &split_at_cmds {
            let sub_wrapped = wrap_comment(sub, max_length, _depth, _indent_width);
            result.extend(sub_wrapped);
        }
        return result;
    }

    if line.len() <= max_length || max_length >= 1000 {
        return vec![line.to_string()];
    }

    let indent = leading_spaces(line);
    let content = line.trim_start();

    // Extract comment marker: !, !>, !<, !* etc.
    let marker = if content.starts_with("!>") {
        "!>"
    } else if content.starts_with("!<") {
        "!<"
    } else if content.starts_with("!*") {
        "!*"
    } else {
        "!"
    };

    // Get the text after the marker (and optional space)
    let text_start = if content.len() > marker.len() && content.as_bytes()[marker.len()] == b' ' {
        marker.len() + 1
    } else {
        marker.len()
    };
    let text = &content[text_start..];

    let prefix = " ".repeat(indent);
    let first_prefix = format!("{}{} ", prefix, marker);
    // Doxygen: first line uses !>, continuation lines use !!
    let cont_marker = if marker == "!>" { "!!" } else { marker };
    let cont_prefix = format!("{}{} ", prefix, cont_marker);
    let first_avail = if max_length > first_prefix.len() {
        max_length - first_prefix.len()
    } else {
        40
    };
    let cont_avail = if max_length > cont_prefix.len() {
        max_length - cont_prefix.len()
    } else {
        40
    };

    // Split text at word boundaries
    let words: Vec<&str> = text.split_whitespace().collect();
    let mut result: Vec<String> = Vec::new();
    let mut current_line = String::new();
    let mut is_first = true;

    for word in &words {
        let avail = if is_first { first_avail } else { cont_avail };
        if current_line.is_empty() {
            current_line = word.to_string();
        } else if current_line.len() + 1 + word.len() <= avail {
            current_line.push(' ');
            current_line.push_str(word);
        } else {
            let pfx = if is_first {
                &first_prefix
            } else {
                &cont_prefix
            };
            result.push(format!("{}{}", pfx, current_line));
            current_line = word.to_string();
            is_first = false;
        }
    }
    if !current_line.is_empty() {
        let pfx = if is_first {
            &first_prefix
        } else {
            &cont_prefix
        };
        result.push(format!("{}{}", pfx, current_line));
    }

    if result.is_empty() {
        vec![line.to_string()]
    } else {
        result
    }
}

/// Find `!<` in a line outside strings. Returns byte offset or None.
fn find_inline_doxygen_in_line(line: &str) -> Option<usize> {
    let bytes = line.as_bytes();
    let mut in_string = false;
    let mut quote_char = b' ';
    let len = bytes.len();
    let mut i = 0;
    while i < len {
        if in_string {
            if bytes[i] == quote_char {
                if i + 1 < len && bytes[i + 1] == quote_char {
                    i += 2;
                    continue;
                }
                in_string = false;
            }
            i += 1;
            continue;
        }
        if bytes[i] == b'\'' || bytes[i] == b'"' {
            in_string = true;
            quote_char = bytes[i];
            i += 1;
            continue;
        }
        if bytes[i] == b'!' && i + 1 < len && bytes[i + 1] == b'<' {
            return Some(i);
        }
        if bytes[i] == b'!' {
            break;
        }
        i += 1;
    }
    None
}

/// Re-wrap `!<` inline Doxygen comments and their `!!` continuation lines.
///
/// Pattern: a code line ending with `!<` text, optionally followed by `!!` continuation
/// lines. The full comment text is joined, and if it fits on the declaration line within
/// `max_length`, it stays there. Otherwise, `!<` is placed on the declaration line and
/// the comment is wrapped as `!!` continuation lines below.
fn rewrap_inline_doxygen(lines: &[String], max_length: usize) -> Vec<String> {
    let mut result: Vec<String> = Vec::new();
    let mut i = 0;

    while i < lines.len() {
        let line = &lines[i];

        // Check if this line has !< outside strings
        let doxygen_pos = match find_inline_doxygen_in_line(line) {
            Some(pos) => pos,
            None => {
                result.push(line.clone());
                i += 1;
                continue;
            }
        };

        // Extract the code part (before !<) and comment text (after !<)
        let code_part = line[..doxygen_pos].trim_end();

        // If there's no code before !<, this is a standalone !< comment (not inline).
        // Convert it to a !> comment with proper indentation.
        if code_part.is_empty() {
            let indent = leading_spaces(line);
            let comment_part = &line[doxygen_pos..];
            let text = if comment_part.len() > 2 {
                let after = &comment_part[2..];
                after.strip_prefix(' ').unwrap_or(after)
            } else {
                ""
            };
            if text.trim().is_empty() {
                // Bare standalone !< with no text — drop it
            } else {
                result.push(format!("{}!> {}", " ".repeat(indent), text.trim()));
            }
            i += 1;
            continue;
        }

        let comment_part = &line[doxygen_pos..]; // "!< text..."
        let comment_text = if comment_part.len() > 2 {
            let after_marker = &comment_part[2..]; // after "!<"
            after_marker.strip_prefix(' ').unwrap_or(after_marker)
        } else {
            ""
        };

        // Collect any following !! continuation lines
        let mut full_text = comment_text.to_string();
        let indent = leading_spaces(line);
        let mut j = i + 1;

        // Skip blank lines between !< and !! continuations, but only if
        // there actually are !! continuations after them
        let mut peek = j;
        while peek < lines.len() && lines[peek].trim().is_empty() {
            peek += 1;
        }
        if peek < lines.len()
            && lines[peek].trim_start().starts_with("!!")
            && !lines[peek].trim_start().starts_with("!!>")
        {
            j = peek; // skip blanks, there are !! continuations
        }

        while j < lines.len() {
            let next = lines[j].trim_start();
            if next.starts_with("!!") && !next.starts_with("!!>") {
                let cont = &next[2..];
                let cont = cont.strip_prefix(' ').unwrap_or(cont);
                // Don't join if it starts with @ (separate Doxygen command)
                let cont_trimmed = cont.trim();
                if cont_trimmed.starts_with('@') {
                    break;
                }
                // Skip blank !! continuation lines
                if cont_trimmed.is_empty() {
                    j += 1;
                    continue;
                }
                full_text.push(' ');
                full_text.push_str(cont_trimmed);
                j += 1;
            } else if next.is_empty() {
                // Skip blank lines between !! continuation lines,
                // but only if there's a !! continuation after them
                let mut peek2 = j + 1;
                while peek2 < lines.len() && lines[peek2].trim().is_empty() {
                    peek2 += 1;
                }
                if peek2 < lines.len()
                    && lines[peek2].trim_start().starts_with("!!")
                    && !lines[peek2].trim_start().starts_with("!!>")
                {
                    j += 1;
                } else {
                    break;
                }
            } else {
                break;
            }
        }

        // Normalize internal whitespace (collapse multiple spaces)
        let full_text: String = full_text
            .split_whitespace()
            .collect::<Vec<&str>>()
            .join(" ");

        // Try to fit the full comment on the declaration line
        if full_text.is_empty() {
            // Bare !< with no text and no !! continuations — drop the !<
            result.push(code_part.to_string());
        } else {
            // Two spaces before the inline marker: that is the final form
            // (S102); building it with one space made the fit check pass on
            // lines that ended up one character over after alignment.
            let one_line = format!("{}  !< {}", code_part, full_text);
            if one_line.len() <= max_length {
                result.push(one_line);
            } else {
                // Won't fit on one line: emit as !> block comment above the declaration
                let prefix_first = format!("{}!> ", " ".repeat(indent));
                let prefix_cont = format!("{}!! ", " ".repeat(indent));
                let avail_first = if max_length > prefix_first.len() {
                    max_length - prefix_first.len()
                } else {
                    40
                };
                let avail_cont = if max_length > prefix_cont.len() {
                    max_length - prefix_cont.len()
                } else {
                    40
                };
                let words: Vec<&str> = full_text.split_whitespace().collect();
                let mut current = String::new();
                let mut is_first = true;
                for word in &words {
                    let avail = if is_first { avail_first } else { avail_cont };
                    if current.is_empty() {
                        current = word.to_string();
                    } else if current.len() + 1 + word.len() <= avail {
                        current.push(' ');
                        current.push_str(word);
                    } else {
                        let pfx = if is_first {
                            &prefix_first
                        } else {
                            &prefix_cont
                        };
                        result.push(format!("{}{}", pfx, current));
                        current = word.to_string();
                        is_first = false;
                    }
                }
                if !current.is_empty() {
                    let pfx = if is_first {
                        &prefix_first
                    } else {
                        &prefix_cont
                    };
                    result.push(format!("{}{}", pfx, current));
                }
                result.push(code_part.to_string());
            }
        }

        i = j; // skip past the consumed !! continuation lines
        continue;
    }

    result
}

/// Remove blank lines between consecutive `use` statements.
fn compact_use_statements(lines: &[String]) -> Vec<String> {
    let mut result: Vec<String> = Vec::new();

    for line in lines {
        let trimmed = line.trim().to_ascii_lowercase();
        let is_use = trimmed.starts_with("use ") || trimmed == "use";

        // If this is a use statement, remove preceding blank lines
        // if the line before the blank was also a use statement
        if is_use && result.last().is_some_and(|l| l.trim().is_empty()) {
            // Check if there's a use statement before the blank
            let last_non_blank = result.iter().rev().find(|l| !l.trim().is_empty());
            if let Some(prev) = last_non_blank {
                let prev_lower = prev.trim().to_ascii_lowercase();
                if prev_lower.starts_with("use ") || prev_lower == "use" {
                    // Remove the blank line(s)
                    while result.last().is_some_and(|l| l.trim().is_empty()) {
                        result.pop();
                    }
                }
            }
        }

        result.push(line.clone());
    }

    result
}

/// Ensure one blank line before and after `implicit none`.
/// The blank line before separates it from `use` statements.
/// The blank line after separates it from declarations.
fn ensure_implicit_none_spacing(lines: &[String]) -> Vec<String> {
    let mut result = Vec::with_capacity(lines.len() + 4);
    for (i, line) in lines.iter().enumerate() {
        let trimmed = line.trim().to_ascii_lowercase();
        if trimmed == "implicit none" {
            // Ensure blank line before (unless at start or already blank)
            if i > 0 {
                let prev = result
                    .last()
                    .map(|s: &String| s.trim().is_empty())
                    .unwrap_or(true);
                if !prev {
                    result.push(String::new());
                }
            }
            result.push(line.clone());
            // Ensure blank line after (unless next is blank or end)
            if i + 1 < lines.len() && !lines[i + 1].trim().is_empty() {
                result.push(String::new());
            }
        } else {
            result.push(line.clone());
        }
    }
    result
}

/// Remove blank lines immediately before block closers/continuations
/// and immediately after block openers.
fn remove_blanks_before_closers(lines: &[String]) -> Vec<String> {
    let mut result: Vec<String> = Vec::new();
    let mut prev_was_opener = false;

    for line in lines {
        let trimmed = line.trim().to_ascii_lowercase();
        let trimmed_raw = line.trim();

        // Doxygen group closer: !> @}
        let is_doxygen_closer = trimmed_raw == "!> @}";
        // Doxygen group opener: !> @{
        let is_doxygen_opener = trimmed_raw == "!> @{";

        // Use the classifier for the Fortran constructs: naive prefix
        // matching fired on ordinary identifiers (blocksize, critical_temp,
        // else_branch) and class(...) declarations.
        let fort_kind = classify(trimmed_raw);
        let is_fort_closer = matches!(
            fort_kind,
            LineKind::FortranBlockClose | LineKind::FortranContinuation | LineKind::FortranContains
        );
        let is_closer = is_fort_closer
            || trimmed.starts_with("#:else")
            || trimmed.starts_with("#:elif")
            || trimmed.starts_with("#:endif")
            || trimmed.starts_with("#:endfor")
            || trimmed.starts_with("#:enddef")
            || trimmed.starts_with("#:endcall")
            || trimmed.starts_with("#:endblock")
            || trimmed.starts_with("#:endmute")
            || trimmed.starts_with("#endif")
            || trimmed.starts_with("#else")
            || is_doxygen_closer;

        // Remove blank lines before closers (but not before end subroutine/function
        // or contains — those get a blank line for visual separation)
        let is_proc_end =
            trimmed.starts_with("end subroutine") || trimmed.starts_with("end function");
        let is_contains = trimmed == "contains";
        if is_closer && !is_proc_end && !is_contains {
            while result.last().is_some_and(|l| l.trim().is_empty()) {
                result.pop();
            }
        }

        // Remove blank lines after openers
        if prev_was_opener && trimmed.is_empty() {
            prev_was_opener = false;
            continue; // skip this blank line
        }

        // Only EXECUTABLE construct openers (if/do/select/block/...) remove a
        // following blank; procedure/module/type openers keep theirs.
        let is_exec_opener = fort_kind == LineKind::FortranBlockOpen
            && !is_procedure_opener(trimmed_raw)
            && !is_module_or_program(trimmed_raw)
            && !trimmed.starts_with("type")
            && !trimmed.starts_with("interface")
            && !trimmed.starts_with("abstract");
        let is_opener = (is_exec_opener
            || trimmed.starts_with("#:if ")
            || trimmed.starts_with("#:for ")
            || trimmed.starts_with("#:call ")
            || trimmed.starts_with("#:def ")
            || trimmed.starts_with("#ifdef")
            || trimmed.starts_with("#ifndef")
            || trimmed.starts_with("#if ")
            || is_doxygen_opener)
            // Continuations act as both closer and opener (else, case, #else, #elif, etc.)
            || fort_kind == LineKind::FortranContinuation
            || trimmed.starts_with("#else")
            || trimmed.starts_with("#elif")
            || trimmed.starts_with("#:else")
            || trimmed.starts_with("#:elif");

        prev_was_opener = is_opener;

        result.push(line.clone());
    }

    result
}

/// Check if a line is a major block closer (end subroutine/function/module/program/type/interface).
/// These are logical unit boundaries that should be separated from following Doxygen comments.
fn is_major_end_block(line: &str) -> bool {
    let trimmed = line.trim().to_ascii_lowercase();
    let re = regex::Regex::new(
        r"(?i)^end\s+(subroutine|function|module|submodule|program|type|interface)\b",
    )
    .unwrap();
    re.is_match(&trimmed)
}

/// Check if a line is `end subroutine` or `end function`.
fn is_end_procedure_line(line: &str) -> bool {
    let lower = line.trim().to_ascii_lowercase();
    lower.starts_with("end subroutine") || lower.starts_with("end function")
}

/// Check if a line is `end module`, `end submodule`, or `end program`.
fn is_end_enclosing_block(line: &str) -> bool {
    let lower = line.trim().to_ascii_lowercase();
    lower.starts_with("end module")
        || lower.starts_with("end submodule")
        || lower.starts_with("end program")
}

/// Check if a line is a `module` or `program` opener (not `module procedure`).
fn is_module_or_program(line: &str) -> bool {
    let lower = line.trim().to_ascii_lowercase();
    (lower.starts_with("module ") && !lower.starts_with("module procedure"))
        || lower.starts_with("program ")
        || lower.starts_with("submodule ")
}

/// Check if a line is a subroutine/function opener.
fn is_procedure_opener(line: &str) -> bool {
    let lower = line.trim().to_ascii_lowercase();
    let re = regex::Regex::new(
        r"(?i)^(?:(?:pure|elemental|impure|recursive|module|integer|real|double\s+precision|complex|character|logical|type\s*\([^)]*\))\s+)*(subroutine|function)\b"
    ).unwrap();
    re.is_match(&lower)
}

/// Ensure blank lines at structural boundaries:
/// 1. Between major block closers and `!>` Doxygen block comments
/// 2. Between `end subroutine`/`end function` and `end module`/`end program`
/// 3. Before `end subroutine`/`end function` (visual separation of procedure body)
/// 4. After `module`/`program`/`subroutine`/`function` openers
/// 5. Before `contains`
fn ensure_blank_after_end(lines: &[String]) -> Vec<String> {
    let mut result: Vec<String> = Vec::with_capacity(lines.len());

    for line in lines {
        if let Some(prev) = result.last().filter(|p| !p.trim().is_empty()) {
            let trimmed = line.trim_start();
            let needs_blank =
                // !> after a major end block
                (trimmed.starts_with("!>") && is_major_end_block(prev))
                // end module/program after end subroutine/function
                || (is_end_enclosing_block(line) && is_end_procedure_line(prev))
                // blank line before end subroutine/function
                || is_end_procedure_line(line)
                // blank line after module/program/subroutine/function opener
                // (but not if the opener has a continuation, or current line is a continuation)
                || ((is_module_or_program(prev) || is_procedure_opener(prev))
                    && !line.trim().is_empty()
                    && !prev.trim_end().ends_with('&')
                    && !trimmed.starts_with('&'))
                // blank line before contains
                || trimmed.eq_ignore_ascii_case("contains");
            if needs_blank {
                result.push(String::new());
            }
        }
        result.push(line.clone());
    }

    result
}

/// Join consecutive `!` comment lines at the same indent when they fit on one line.
/// Does not join Doxygen (`!>`, `!!`, `!<`) or directive (`!$`) comments.
fn join_short_comments(lines: &[String], max_length: usize) -> Vec<String> {
    let mut result: Vec<String> = Vec::new();
    let mut i = 0;

    while i < lines.len() {
        let line = &lines[i];
        let trimmed = line.trim_start();

        // Detect which comment marker this line uses
        let marker = if trimmed.starts_with("!! ") && !trimmed.starts_with("!!>") {
            let text_after = &trimmed[3..];
            if text_after.starts_with('@') {
                None
            } else {
                Some("!!")
            }
        } else if trimmed.starts_with("! ")
            && !trimmed.starts_with("!> ")
            && !trimmed.starts_with("!< ")
            && !trimmed.starts_with("!$ ")
            && !trimmed.starts_with("!* ")
            && !trimmed.starts_with("!@ ")
        {
            Some("!")
        } else {
            None
        };

        if let Some(mk) = marker {
            let mk_len = mk.len();
            let indent = leading_spaces(line);
            let prefix = format!("{}{} ", " ".repeat(indent), mk);
            let avail = if max_length > prefix.len() {
                max_length - prefix.len()
            } else {
                40
            };

            // Collect ALL consecutive comment lines with the same marker and indent
            let mut full_text = trimmed[mk_len + 1..].to_string();
            let mut j = i + 1;

            while j < lines.len() {
                let next = &lines[j];
                let next_trimmed = next.trim_start();
                let next_indent = leading_spaces(next);
                let next_matches = next_indent == indent
                    && next_trimmed.starts_with(mk)
                    && next_trimmed.len() > mk_len
                    && next_trimmed.as_bytes()[mk_len] == b' ';
                let next_matches =
                    next_matches && !(mk == "!!" && next_trimmed[mk_len + 1..].starts_with('@'));

                if next_matches {
                    full_text.push(' ');
                    full_text.push_str(&next_trimmed[mk_len + 1..]);
                    j += 1;
                } else {
                    break;
                }
            }

            // If only one line, emit as-is
            if j == i + 1 {
                result.push(line.clone());
                i = j;
                continue;
            }

            // Only join when the combined text fits on a single line.
            // When each line is already at a reasonable length, they are
            // independent comments and merging them would destroy the
            // author's intent (and can cause a moved trailing comment to
            // be merged into an unrelated preceding standalone comment).
            if full_text.len() > avail {
                for line in lines.iter().take(j).skip(i) {
                    result.push(line.clone());
                }
                i = j;
                continue;
            }

            result.push(format!("{}{}", prefix, full_text));
            i = j;
        } else {
            result.push(line.clone());
            i += 1;
        }
    }

    result
}

/// Check if a line is a Fortran declaration (has `::` outside strings/comments,
/// or is `implicit none`, `use`, etc.).
fn is_declaration_line(line: &str) -> bool {
    let trimmed = line.trim();
    if trimmed.is_empty() {
        return false;
    }
    let lower = trimmed.to_ascii_lowercase();

    // implicit none, use statements
    if lower.starts_with("implicit ") || lower.starts_with("use ") || lower == "use" {
        return true;
    }

    // Lines with :: outside strings and comments
    let bytes = trimmed.as_bytes();
    let mut in_string = false;
    let mut quote_char = b' ';
    let mut i = 0;
    while i < bytes.len() {
        if in_string {
            if bytes[i] == quote_char {
                if i + 1 < bytes.len() && bytes[i + 1] == quote_char {
                    i += 2; // escaped quote — stay in string
                    continue;
                }
                in_string = false;
            }
            i += 1;
            continue;
        }
        if bytes[i] == b'\'' || bytes[i] == b'"' {
            in_string = true;
            quote_char = bytes[i];
            i += 1;
            continue;
        }
        if bytes[i] == b'!' {
            break; // rest is comment
        }
        if bytes[i] == b':' && i + 1 < bytes.len() && bytes[i + 1] == b':' {
            return true;
        }
        i += 1;
    }
    false
}

/// Ensure at least 2 spaces before inline `!` comments on code lines.
/// This satisfies Fortitude rule S102. Only applies to lines that have both
/// code and a trailing comment (not standalone comment lines, not `!$` directives,
/// not `!>` or `!<` Doxygen, not Fypp lines).
fn ensure_two_spaces_before_inline_comment(lines: &[String], line_length: usize) -> Vec<String> {
    lines
        .iter()
        .map(|line| {
            let trimmed = line.trim_start();
            // Skip blank lines, standalone comments, preprocessor, Fypp
            if trimmed.is_empty()
                || trimmed.starts_with('!')
                || trimmed.starts_with('#')
                || trimmed.starts_with("$:")
                || trimmed.starts_with("@:")
            {
                return line.clone();
            }
            // Find the inline comment position (! outside strings)
            let bytes = line.as_bytes();
            let mut in_string = false;
            let mut delim = b' ';
            let mut i = 0;
            while i < bytes.len() {
                if in_string {
                    if bytes[i] == delim {
                        // Check for doubled quote escape
                        if i + 1 < bytes.len() && bytes[i + 1] == delim {
                            i += 2; // escaped quote — stay in string
                            continue;
                        }
                        in_string = false;
                    }
                } else if bytes[i] == b'\'' || bytes[i] == b'"' {
                    in_string = true;
                    delim = bytes[i];
                } else if bytes[i] == b'!' {
                    // Found inline comment. Check spacing before it.
                    if i == 0 {
                        return line.clone();
                    } // standalone comment
                      // Skip directives (!$acc, !$omp), standalone Doxygen (!>), and !! continuations
                    let rest = &line[i..];
                    if rest.starts_with("!$") || rest.starts_with("!>") || rest.starts_with("!!") {
                        return line.clone();
                    }
                    // Count spaces before the `!`
                    let mut spaces = 0;
                    let mut j = i;
                    while j > 0 && bytes[j - 1] == b' ' {
                        spaces += 1;
                        j -= 1;
                    }
                    if j == 0 {
                        return line.clone();
                    } // all spaces before ! = standalone
                    if spaces >= 2 {
                        return line.clone();
                    } // already ok
                      // S102 (two spaces before inline comments) wins over
                      // line length: collapse_double_spaces narrows these
                      // junctions earlier in the pipeline, and refusing to
                      // re-widen here would flip-flop between runs on lines
                      // near the limit.
                    let _ = line_length;
                    let code_part = &line[..i].trim_end();
                    let comment_part = &line[i..];
                    return format!("{}  {}", code_part, comment_part);
                }
                i += 1;
            }
            line.clone()
        })
        .collect()
}

/// Strip trailing semicolons from lines (Fortitude S081).
/// Removes `;` at the end of a line (before any trailing comment), but only if
/// it's truly trailing — not between statements on the same line.
fn strip_trailing_semicolons(lines: &[String]) -> Vec<String> {
    lines
        .iter()
        .map(|line| {
            let trimmed = line.trim_start();
            // Skip blank, comment-only, preprocessor, Fypp lines
            if trimmed.is_empty()
                || trimmed.starts_with('!')
                || trimmed.starts_with('#')
                || trimmed.starts_with("$:")
                || trimmed.starts_with("@:")
            {
                return line.clone();
            }
            // Find the code portion (before any inline comment)
            let bytes = line.as_bytes();
            let mut in_string = false;
            let mut delim = b' ';
            let mut comment_start = bytes.len();
            let mut i = 0;
            while i < bytes.len() {
                if in_string {
                    if bytes[i] == delim {
                        if i + 1 < bytes.len() && bytes[i + 1] == delim {
                            i += 2; // escaped quote — stay in string
                            continue;
                        }
                        in_string = false;
                    }
                } else if bytes[i] == b'\'' || bytes[i] == b'"' {
                    in_string = true;
                    delim = bytes[i];
                } else if bytes[i] == b'!' {
                    comment_start = i;
                    break;
                }
                i += 1;
            }
            let code = &line[..comment_start];
            let comment = &line[comment_start..];
            let code_trimmed = code.trim_end();
            if code_trimmed.ends_with(';') {
                let stripped = code_trimmed.trim_end_matches(';').trim_end();
                if stripped.is_empty() && comment.is_empty() {
                    return String::new(); // Line was only semicolons
                }
                if comment.is_empty() {
                    return format!(
                        "{}{}",
                        &line[..line.len() - line.trim_start().len()],
                        stripped.trim_start()
                    );
                } else {
                    return format!("{}  {}", stripped, comment.trim_start());
                }
            }
            line.clone()
        })
        .collect()
}

/// Ensure a blank line between the last declaration and the first executable statement
/// within procedure bodies. Only acts when there is no blank line already.
fn separate_declarations_from_code(lines: &[String]) -> Vec<String> {
    let mut result: Vec<String> = Vec::with_capacity(lines.len());
    // Track whether we're in a declaration region at the top of a procedure
    let mut in_decl_region = false;
    let mut saw_declaration = false;
    let proc_re = regex::Regex::new(
        r"(?i)^(?:(?:pure|elemental|impure|recursive|module|integer|real|double\s+precision|complex|character|logical|type\s*\([^)]*\))\s+)*(subroutine|function)\b"
    ).unwrap();

    for line in lines {
        let trimmed = line.trim();
        let lower = trimmed.to_ascii_lowercase();

        // Detect procedure entry (subroutine/function opening)
        if !trimmed.is_empty() && !lower.starts_with("end ") {
            let is_proc_start = proc_re.is_match(&lower);
            if is_proc_start {
                in_decl_region = true;
                saw_declaration = false;
                result.push(line.clone());
                continue;
            }
        }

        if in_decl_region {
            // Skip blank lines, comments, and continuation lines
            // (preprocessor #ifdef/#ifndef/#if end the declaration region below)
            if trimmed.is_empty() || trimmed.starts_with('!') || trimmed.starts_with('&') {
                result.push(line.clone());
                continue;
            }

            // Preprocessor #endif/#else/#include/#define — skip without ending region
            if trimmed.starts_with("#endif")
                || trimmed.starts_with("#else")
                || trimmed.starts_with("#elif")
                || trimmed.starts_with("#include")
                || trimmed.starts_with("#define")
                || trimmed.starts_with("#undef")
            {
                result.push(line.clone());
                continue;
            }

            // Fypp lines ($:, @:, #:) and #ifdef/#ifndef/#if — end the declaration region
            if trimmed.starts_with("@:")
                || trimmed.starts_with("$:")
                || trimmed.starts_with("#:")
                || trimmed.starts_with("#ifdef")
                || trimmed.starts_with("#ifndef")
                || trimmed.starts_with("#if ")
            {
                in_decl_region = false;
                if saw_declaration {
                    let has_blank = result.last().is_some_and(|l| l.trim().is_empty());
                    if !has_blank {
                        result.push(String::new());
                    }
                }
                result.push(line.clone());
                continue;
            }

            if is_declaration_line(line) {
                saw_declaration = true;
                result.push(line.clone());
                continue;
            }

            // First non-declaration, non-blank, non-comment line: end decl region
            in_decl_region = false;
            // Only insert blank if we actually saw declarations
            if saw_declaration {
                let has_blank = result.last().is_some_and(|l| l.trim().is_empty());
                if !has_blank {
                    result.push(String::new());
                }
            }
        }

        result.push(line.clone());
    }

    result
}

/// Re-wrap a long Fypp macro line ($: or @:) at top-level argument commas.
/// Only breaks between macro arguments (paren depth 1), never inside strings.
/// Continuation lines are aligned with the opening paren using & continuation.
fn rewrap_fypp_line(line: &str, max_length: usize) -> Vec<String> {
    let indent = leading_spaces(line);
    let content = line.trim_start();

    // Find the opening paren
    let paren_pos = match content.find('(') {
        Some(p) => p,
        None => return vec![line.to_string()],
    };

    let cont_indent = indent + paren_pos + 1;
    let cont_prefix = " ".repeat(cont_indent);

    // Walk the content and find comma positions where we can break.
    // Break at: top-level commas (paren depth 1, between macro args)
    // AND commas inside '[...]' Fypp list strings (safe to break with &).
    let bytes = content.as_bytes();
    let len = bytes.len();
    let mut depth = 0i32;
    let mut in_string = false;
    let mut in_fypp_list = false; // inside '[...]' within a string
    let mut quote_char = b' ';
    let mut comma_positions: Vec<usize> = Vec::new();

    let mut i = 0;
    while i < len {
        let b = bytes[i];
        if in_string {
            if b == quote_char {
                if i + 1 < len && bytes[i + 1] == quote_char {
                    i += 2; // escaped quote — stay in string
                    continue;
                }
                in_string = false;
                in_fypp_list = false;
            } else if b == b'[' && !in_fypp_list {
                in_fypp_list = true;
            } else if b == b']' && in_fypp_list {
                in_fypp_list = false;
            } else if b == b',' && in_fypp_list {
                // Comma inside '[...]' Fypp list — safe to break
                let mut end = i + 1;
                if end < len && bytes[end] == b' ' {
                    end += 1;
                }
                comma_positions.push(end);
            }
            i += 1;
            continue;
        }
        if b == b'\'' || b == b'"' {
            in_string = true;
            quote_char = b;
            i += 1;
            continue;
        }
        if b == b'(' {
            depth += 1;
        } else if b == b')' {
            depth -= 1;
        } else if b == b',' && depth == 1 {
            // Top-level comma between macro arguments
            let mut end = i + 1;
            if end < len && bytes[end] == b' ' {
                end += 1;
            }
            comma_positions.push(end);
        }
        i += 1;
    }

    if comma_positions.is_empty() {
        return vec![line.to_string()];
    }

    // Build lines by greedily packing content until exceeding max_length
    let indent_str = " ".repeat(indent);
    let cont_with_amp = format!("{}& ", cont_prefix);

    let mut result: Vec<String> = Vec::new();
    let mut chunk_start = 0usize;

    for &comma_end in &comma_positions {
        // +2 accounts for trailing " &" on non-last lines
        let current_len = if result.is_empty() {
            indent + comma_end + 2
        } else {
            cont_with_amp.len() + (comma_end - chunk_start) + 2
        };

        if current_len > max_length && chunk_start < comma_end {
            // Find the last comma that fits (including trailing " &")
            let mut best_break = chunk_start;
            for &cp in &comma_positions {
                if cp <= chunk_start {
                    continue;
                }
                let len_with_break = if result.is_empty() {
                    indent + cp + 2
                } else {
                    cont_with_amp.len() + (cp - chunk_start) + 2
                };
                if len_with_break <= max_length {
                    best_break = cp;
                } else {
                    break;
                }
            }

            if best_break > chunk_start {
                let segment = content[chunk_start..best_break].trim_end();
                if result.is_empty() {
                    result.push(format!("{}{} &", indent_str, segment));
                } else {
                    result.push(format!("{}& {} &", cont_prefix, segment));
                }
                chunk_start = best_break;
                // Skip leading space
                while chunk_start < len && bytes[chunk_start] == b' ' {
                    chunk_start += 1;
                }
            }
        }
    }

    // Emit remaining content
    let remaining = content[chunk_start..].trim_end();
    if result.is_empty() {
        result.push(format!("{}{}", indent_str, remaining));
    } else {
        result.push(format!("{}& {}", cont_prefix, remaining));
    }

    result
}

/// Token-aware line re-wrapping. Breaks a long line at token boundaries,
/// preferring commas, then operators. Never breaks inside tokens (strings,
/// numbers, identifiers), preventing issues like splitting `1.e-32`.
///
/// Returns a Vec of output lines. If the input fits within `max_length`,
/// returns it unchanged as a single-element Vec.
fn rewrap_line(line: &str, max_length: usize, indent_width: usize) -> Vec<String> {
    if line.len() <= max_length || max_length >= 1000 {
        return vec![line.to_string()];
    }

    // Full-line comments and directives never belong here: relocating or
    // wrapping them corrupts the marker (`!$acc` would become a comment).
    if line.trim_start().starts_with('!') {
        return vec![line.to_string()];
    }

    // Don't wrap lines with !< inline doxygen — rewrap_inline_doxygen handles these
    if find_inline_doxygen_in_line(line).is_some() {
        return vec![line.to_string()];
    }

    let indent = leading_spaces(line);
    let content = line.trim_start();

    // Split off trailing comment so we never produce a dangling "& ! comment" line.
    // We wrap only the code portion; the comment is reattached to the last line.
    let (code_content, trailing_comment) = split_trailing_comment(content);
    let code_content = code_content.trim_end();

    // If the code portion (with indent) fits but the comment makes it too long,
    // move the comment to its own line(s) above the code.
    if !trailing_comment.is_empty() && indent + code_content.len() <= max_length {
        let comment_line = format!("{}{}", " ".repeat(indent), trailing_comment.trim());
        let mut result = wrap_comment(&comment_line, max_length, 0, indent_width);
        result.push(format!("{}{}", " ".repeat(indent), code_content));
        return result;
    }

    // Use code-only content for wrapping; comment will be appended at the end
    let content = if trailing_comment.is_empty() {
        content
    } else {
        code_content
    };

    // Find token boundaries in the content
    let breaks = find_token_breaks(content);

    if breaks.is_empty() {
        return vec![line.to_string()];
    }

    // Align continuation with opening paren (string-aware), capped to avoid extreme indentation
    let flat_indent = indent + indent_width;
    let paren_col = {
        let mut in_str = false;
        let mut qch = b' ';
        let mut found = None;
        let cb = content.as_bytes();
        let mut i = 0;
        while i < cb.len() {
            if in_str {
                if cb[i] == qch {
                    if i + 1 < cb.len() && cb[i + 1] == qch {
                        i += 2; // escaped quote — stay in string
                        continue;
                    }
                    in_str = false;
                }
                i += 1;
                continue;
            }
            if cb[i] == b'\'' || cb[i] == b'"' {
                in_str = true;
                qch = cb[i];
                i += 1;
                continue;
            }
            if cb[i] == b'(' {
                found = Some(indent + i + 1);
                break;
            }
            i += 1;
        }
        found
    };
    let cont_indent = match paren_col {
        Some(col) if col <= indent + 40 => col,
        _ => flat_indent,
    };
    let cont_prefix = " ".repeat(cont_indent);

    // Available width: max_length minus indent minus " &" suffix
    let first_avail = max_length.saturating_sub(indent + 2);
    let cont_avail = max_length.saturating_sub(cont_indent + 4); // "& " prefix + " &" suffix

    let mut result: Vec<String> = Vec::new();
    let mut pos = 0usize;
    let mut is_first = true;

    while pos < content.len() {
        let avail = if is_first { first_avail } else { cont_avail };
        let remaining = &content[pos..];

        // If remaining fits, emit as final line
        if remaining.len() <= avail + 2 {
            if is_first {
                result.push(format!("{}{}", " ".repeat(indent), remaining));
            } else {
                result.push(format!("{}& {}", cont_prefix, remaining));
            }
            break;
        }

        // Find best break point within avail characters
        let abs_limit = pos + avail;

        // Walk through token breaks, find the last one before the limit
        // Prefer commas (BreakKind::Comma) over operators
        let mut last_comma = 0usize;
        let mut last_other = 0usize;

        for &(bp, kind) in &breaks {
            if bp <= pos {
                continue;
            }
            let rel = bp - pos;
            if bp > abs_limit {
                break;
            }
            match kind {
                BreakKind::Comma => last_comma = rel,
                _ => last_other = rel,
            }
        }

        let best_break = if last_comma > 0 {
            last_comma
        } else if last_other > 0 {
            last_other
        } else {
            // No break within limit — find first comma AFTER limit (long string literal)
            let mut first_comma_after = 0usize;
            for &(bp, kind) in &breaks {
                if bp <= pos {
                    continue;
                }
                if matches!(kind, BreakKind::Comma) {
                    first_comma_after = bp - pos;
                    break;
                }
            }
            if first_comma_after > 0 {
                first_comma_after
            } else {
                // No break point at all — emit as-is
                if is_first {
                    result.push(format!("{}{}", " ".repeat(indent), remaining));
                } else {
                    result.push(format!("{}& {}", cont_prefix, remaining));
                }
                break;
            }
        };

        let chunk = content[pos..pos + best_break].trim_end();
        if is_first {
            result.push(format!("{}{} &", " ".repeat(indent), chunk));
        } else {
            result.push(format!("{}& {} &", cont_prefix, chunk));
        }

        pos += best_break;
        // Skip leading whitespace on next chunk
        while pos < content.len() && content.as_bytes()[pos] == b' ' {
            pos += 1;
        }
        is_first = false;
    }

    if result.is_empty() {
        vec![line.to_string()]
    } else {
        // Reattach trailing comment to the last line
        if !trailing_comment.is_empty() {
            if let Some(last) = result.last_mut() {
                last.push(' ');
                last.push_str(trailing_comment);
            }
        }
        result
    }
}

/// Split content at the first `!` that starts a trailing comment (outside strings).
/// Returns (code_part, comment_part). Comment part includes the `!`.
fn split_trailing_comment(content: &str) -> (&str, &str) {
    let bytes = content.as_bytes();
    let len = bytes.len();
    let mut in_string = false;
    let mut quote_char = b' ';

    let mut i = 0;
    while i < len {
        let b = bytes[i];
        if in_string {
            if b == quote_char {
                if i + 1 < len && bytes[i + 1] == quote_char {
                    i += 2; // doubled quote escape — stay in string
                    continue;
                }
                in_string = false;
            }
            i += 1;
            continue;
        }
        if b == b'\'' || b == b'"' {
            in_string = true;
            quote_char = b;
            i += 1;
            continue;
        }
        if b == b'!' {
            return (&content[..i], &content[i..]);
        }
        i += 1;
    }
    (content, "")
}

#[derive(Clone, Copy)]
enum BreakKind {
    Comma,
    Operator,
    CloseParen,
}

/// Find positions in `content` where it's safe to break the line.
/// Returns (position_after_break_char, kind) pairs.
/// Positions are byte offsets where a line break can be inserted.
fn find_token_breaks(content: &str) -> Vec<(usize, BreakKind)> {
    let bytes = content.as_bytes();
    let len = bytes.len();
    let mut breaks = Vec::new();
    let mut i = 0;
    let mut in_string = false;
    let mut quote_char = b' ';
    let mut paren_depth = 0i32;
    let mut in_fypp = false; // inside ${...}$ or @{...}@ block

    while i < len {
        let b = bytes[i];

        // Skip entire Fypp inline expressions — never break inside ${...}$ or @{...}@
        if !in_string && !in_fypp && (b == b'$' || b == b'@') && i + 1 < len && bytes[i + 1] == b'{'
        {
            in_fypp = true;
            i += 2;
            let mut depth = 1i32;
            while i < len && depth > 0 {
                if bytes[i] == b'{' {
                    depth += 1;
                } else if bytes[i] == b'}' {
                    depth -= 1;
                    if depth == 0 {
                        i += 1;
                        // consume trailing $ or @
                        if i < len && (bytes[i] == b'$' || bytes[i] == b'@') {
                            i += 1;
                        }
                        in_fypp = false;
                        break;
                    }
                }
                i += 1;
            }
            continue;
        }

        // Track strings — never break inside
        if in_string {
            if b == quote_char {
                if i + 1 < len && bytes[i + 1] == quote_char {
                    i += 2;
                    continue;
                }
                in_string = false;
            }
            i += 1;
            continue;
        }

        if b == b'\'' || b == b'"' {
            in_string = true;
            quote_char = b;
            i += 1;
            continue;
        }

        // Track parens
        if b == b'(' || b == b'[' {
            paren_depth += 1;
        } else if b == b')' || b == b']' {
            paren_depth -= 1;
            if paren_depth < 0 {
                paren_depth = 0;
            }
            // Break after close paren at any depth
            breaks.push((i + 1, BreakKind::CloseParen));
        }

        // Comma — best break point (after the comma + space)
        if b == b',' {
            // Skip trailing space after comma
            let mut end = i + 1;
            if end < len && bytes[end] == b' ' {
                end += 1;
            }
            breaks.push((end, BreakKind::Comma));
        }

        // Dot-keyword logical operators: .and. .or. .eqv. .neqv.
        // Break BEFORE the operator when preceded by a space, so a long
        // `if (a .or. b .or. c)` wraps as `a .or. b &\n& .or. c`.
        // .not. is unary — skip it.  .true./.false. are literals — skip them.
        if !in_string && b == b'.' && i > 0 && bytes[i - 1] == b' ' {
            let rest = &bytes[i..];
            let lo =
                |op: &[u8]| rest.len() >= op.len() && rest[..op.len()].eq_ignore_ascii_case(op);
            if lo(b".and.") || lo(b".or.") || lo(b".eqv.") || lo(b".neqv.") {
                breaks.push((i, BreakKind::Operator));
            }
        }

        // Binary operators at any paren depth
        {
            // Multi-char operators: check for //, /=, ==, <=, >=, =>, **
            if b == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
                // // (concat) — break before it if preceded by space
                if i > 0 && bytes[i - 1] == b' ' {
                    breaks.push((i, BreakKind::Operator));
                }
                i += 2;
                continue;
            }

            // + and - as binary operators (not unary, not exponent)
            if (b == b'+' || b == b'-') && i > 0 {
                let prev = bytes[i - 1];
                // Binary if preceded by space, close paren, alphanumeric, or _
                if prev == b' '
                    || prev == b')'
                    || prev == b']'
                    || prev.is_ascii_alphanumeric()
                    || prev == b'_'
                {
                    // But not if it's part of exponent notation (e+3, d-5)
                    if !(i >= 2
                        && (bytes[i - 1] == b'e'
                            || bytes[i - 1] == b'E'
                            || bytes[i - 1] == b'd'
                            || bytes[i - 1] == b'D')
                        && bytes[i - 2].is_ascii_digit())
                    {
                        // Break before the operator if preceded by space
                        if prev == b' ' {
                            breaks.push((i, BreakKind::Operator));
                        }
                    }
                }
            }
        }

        // Comment starts — stop
        if b == b'!' {
            break;
        }

        i += 1;
    }

    // Never break immediately before `%` (member accessor).  A break there
    // would produce `& %member` on the next line, which is a blank within a
    // lexical token — invalid in Fortran free-form (§6.3.2.2).
    breaks.retain(|&(bp, _)| bp >= len || bytes[bp] != b'%');

    breaks
}

/// Remove space between Fypp macro name and ( on $: and @: lines.
/// E.g., "$:GPU_PARALLEL_LOOP (collapse=3)" -> "$:GPU_PARALLEL_LOOP(collapse=3)"
fn remove_fypp_macro_paren_space(line: &str) -> String {
    use regex::Regex;
    use std::sync::OnceLock;
    static RE: OnceLock<Regex> = OnceLock::new();
    let re = RE.get_or_init(|| Regex::new(r"(?m)^(\s*[@$]:[\w]+)\s+\(").unwrap());
    re.replace(line, r"${1}(").to_string()
}

/// Normalize comma spacing inside Fypp '[...]' list arguments.
/// E.g., `'[x,y,z,Ys_L, Ys_R]'` -> `'[x, y, z, Ys_L, Ys_R]'`
/// Only modifies content inside `'[` ... `]'` patterns.
fn normalize_fypp_lists(line: &str) -> String {
    let mut result = String::with_capacity(line.len());
    let bytes = line.as_bytes();
    let len = bytes.len();
    let mut i = 0;
    // Start of the pending region to copy verbatim (always on a char boundary,
    // since we only advance past ASCII delimiters).
    let mut plain_start = 0;

    while i < len {
        // Look for '[
        if i + 1 < len && bytes[i] == b'\'' && bytes[i + 1] == b'[' {
            // Find the matching ]'
            let mut j = i + 2;
            let mut close = None;
            while j + 1 < len {
                if bytes[j] == b']' && bytes[j + 1] == b'\'' {
                    close = Some(j);
                    break;
                }
                j += 1;
            }
            if let Some(close) = close {
                // Found ]' -- normalize commas in content
                result.push_str(&line[plain_start..i]);
                result.push_str("'[");
                result.push_str(&normalize_comma_spacing(&line[i + 2..close]));
                result.push_str("]'");
                i = close + 2;
                plain_start = i;
                continue;
            }
        }
        i += 1;
    }
    result.push_str(&line[plain_start..]);

    result
}

/// Modernize legacy Fortran relational operators to their modern equivalents.
/// `.eq.` -> `==`, `.ne.` -> `/=`, `.lt.` -> `<`, `.le.` -> `<=`,
/// `.gt.` -> `>`, `.ge.` -> `>=`.
/// Skips string literals, comment lines, preprocessor lines, and Fypp lines.
fn modernize_relational_operators(lines: &[String]) -> Vec<String> {
    let operators: &[(&str, &str)] = &[
        (".eq.", "=="),
        (".ne.", "/="),
        (".le.", "<="),
        (".ge.", ">="),
        (".lt.", "<"),
        (".gt.", ">"),
    ];

    lines
        .iter()
        .map(|line| {
            let trimmed = line.trim_start();
            // Skip comment-only, preprocessor, Fypp lines
            if trimmed.is_empty()
                || trimmed.starts_with('!')
                || trimmed.starts_with('#')
                || trimmed.starts_with("$:")
                || trimmed.starts_with("@:")
            {
                return line.clone();
            }

            let bytes = line.as_bytes();
            let len = bytes.len();
            let mut result = String::with_capacity(len);
            let mut i = 0;
            let mut in_string = false;
            let mut quote_char = b' ';
            // Start of the pending region to copy verbatim (always on a char
            // boundary, since we only cut at ASCII operator positions).
            let mut plain_start = 0;

            while i < len {
                if in_string {
                    if bytes[i] == quote_char {
                        if i + 1 < len && bytes[i + 1] == quote_char {
                            i += 2; // escaped quote — stay in string
                            continue;
                        }
                        in_string = false;
                    }
                    i += 1;
                    continue;
                }

                if bytes[i] == b'\'' || bytes[i] == b'"' {
                    in_string = true;
                    quote_char = bytes[i];
                    i += 1;
                    continue;
                }

                // Comment start — rest is copied verbatim below
                if bytes[i] == b'!' {
                    break;
                }

                // Check for legacy operator
                if bytes[i] == b'.' {
                    let mut matched = None;
                    for &(legacy, modern) in operators {
                        let legacy_len = legacy.len();
                        if i + legacy_len <= len
                            && bytes[i..i + legacy_len].eq_ignore_ascii_case(legacy.as_bytes())
                        {
                            matched = Some((legacy_len, modern));
                            break;
                        }
                    }
                    if let Some((legacy_len, modern)) = matched {
                        result.push_str(&line[plain_start..i]);
                        result.push_str(modern);
                        i += legacy_len;
                        plain_start = i;
                        continue;
                    }
                }

                i += 1;
            }
            result.push_str(&line[plain_start..]);

            result
        })
        .collect()
}

/// Enforce `::` separator in variable declarations that are missing it.
/// E.g., `integer x` -> `integer :: x`, `type(foo) bar` -> `type(foo) :: bar`.
fn enforce_double_colon(lines: &[String]) -> Vec<String> {
    use regex::Regex;
    use std::sync::OnceLock;

    // Match simple type declarations without ::
    // Group 1: indent + type keyword (+ optional kind/attributes)
    // Group 2: variable name(s)
    static RE_SIMPLE: OnceLock<Regex> = OnceLock::new();
    let re_simple = RE_SIMPLE.get_or_init(|| {
        Regex::new(r"(?i)^(\s*(?:integer|real|logical|complex|character|double\s+precision)(?:\s*\([^)]*\))?(?:\s*,\s*(?:intent\s*\([^)]*\)|dimension\s*\([^)]*\)|allocatable|pointer|target|optional|save|parameter|value|contiguous|external|intrinsic|volatile|asynchronous|protected|private|public))*)\s+([a-zA-Z_]\w*.*)$").unwrap()
    });

    // Match type()/class() declarations without ::
    static RE_TYPED: OnceLock<Regex> = OnceLock::new();
    let re_typed = RE_TYPED.get_or_init(|| {
        Regex::new(r"(?i)^(\s*(?:type|class)\s*\([^)]*\)(?:\s*,\s*(?:intent\s*\([^)]*\)|dimension\s*\([^)]*\)|allocatable|pointer|target|optional|save|parameter|value|contiguous|external|intrinsic|volatile|asynchronous|protected|private|public))*)\s+([a-zA-Z_]\w*.*)$").unwrap()
    });

    // Non-declaration keywords that might look like declarations
    static RE_SKIP: OnceLock<Regex> = OnceLock::new();
    let re_skip = RE_SKIP.get_or_init(|| {
        Regex::new(r"(?i)^\s*(?:if|do|else|end|call|return|write|read|print|open|close|format|go\s*to|select|case|where|forall|block|associate|critical|sync|lock|unlock|event|error|stop|exit|cycle|continue|contains|implicit|use|module|program|subroutine|function|interface|abstract|procedure|entry|include|equivalence|common|data|namelist|save|allocate|deallocate|nullify|inquire|rewind|backspace|endfile|flush|wait|type\s+is|class\s+is|class\s+default|rank\s+default|rank\s*\()\b").unwrap()
    });

    lines
        .iter()
        .map(|line| {
            let trimmed = line.trim_start();

            // Skip blank, comment, preprocessor, Fypp lines
            if trimmed.is_empty()
                || trimmed.starts_with('!')
                || trimmed.starts_with('#')
                || trimmed.starts_with("$:")
                || trimmed.starts_with("@:")
                || trimmed.starts_with("#:")
            {
                return line.clone();
            }

            // Skip lines that already contain ::
            if line.contains("::") {
                return line.clone();
            }

            // Skip non-declaration keywords
            if re_skip.is_match(line) {
                return line.clone();
            }

            // Skip function/subroutine signatures (e.g., "logical elemental function f_foo")
            let lower = trimmed.to_ascii_lowercase();
            if lower.contains("function ") || lower.contains("subroutine ") {
                return line.clone();
            }

            // Skip lines ending with & (continuations)
            if trimmed.ends_with('&') {
                return line.clone();
            }

            // Try simple type match
            if let Some(caps) = re_simple.captures(line) {
                let type_part = caps.get(1).unwrap().as_str();
                let var_part = caps.get(2).unwrap().as_str();

                // Don't insert :: if var_part is a keyword (e.g., "integer function foo")
                let var_lower = var_part.trim().to_ascii_lowercase();
                if var_lower.starts_with("function ")
                    || var_lower.starts_with("subroutine ")
                    || var_lower.starts_with("module ")
                    || var_lower.starts_with("program ")
                {
                    return line.clone();
                }

                return format!("{} :: {}", type_part.trim_end(), var_part.trim_start());
            }

            // Try type()/class() match
            if let Some(caps) = re_typed.captures(line) {
                let type_part = caps.get(1).unwrap().as_str();
                let var_part = caps.get(2).unwrap().as_str();

                let var_lower = var_part.trim().to_ascii_lowercase();
                if var_lower.starts_with("function ") || var_lower.starts_with("subroutine ") {
                    return line.clone();
                }

                return format!("{} :: {}", type_part.trim_end(), var_part.trim_start());
            }

            line.clone()
        })
        .collect()
}

/// Split semicolon-separated statements onto separate lines.
/// Preserves `private; public ::` as an idiomatic Fortran pattern.
fn split_multi_statements(lines: &[String], indent_width: usize) -> Vec<String> {
    let mut result: Vec<String> = Vec::new();

    for line in lines {
        let trimmed = line.trim_start();
        // Skip blank, comment, preprocessor, Fypp lines
        if trimmed.is_empty()
            || trimmed.starts_with('!')
            || trimmed.starts_with('#')
            || trimmed.starts_with("$:")
            || trimmed.starts_with("@:")
        {
            result.push(line.clone());
            continue;
        }

        // Check for `private; public ::` pattern (case-insensitive)
        let lower = trimmed.to_ascii_lowercase();
        if lower.starts_with("private") && lower.contains("; public") {
            result.push(line.clone());
            continue;
        }

        // Find semicolons outside strings
        let indent = leading_spaces(line);
        let indent_str = " ".repeat(indent);
        let bytes = trimmed.as_bytes();
        let len = bytes.len();
        let mut in_string = false;
        let mut quote_char = b' ';
        let mut semicolons: Vec<usize> = Vec::new();

        let mut i = 0;
        while i < len {
            if in_string {
                if bytes[i] == quote_char {
                    if i + 1 < len && bytes[i + 1] == quote_char {
                        i += 2; // escaped quote — stay in string
                        continue;
                    }
                    in_string = false;
                }
                i += 1;
                continue;
            }
            if bytes[i] == b'\'' || bytes[i] == b'"' {
                in_string = true;
                quote_char = bytes[i];
                i += 1;
                continue;
            }
            if bytes[i] == b'!' {
                break; // rest is comment
            }
            if bytes[i] == b';' {
                semicolons.push(i);
            }
            i += 1;
        }

        if semicolons.is_empty() {
            result.push(line.clone());
            continue;
        }

        // Split at semicolons. Pieces are re-indented relative to the parent
        // line by classifying each one: an opener indents what follows it,
        // a closer/continuation dedents itself (if (...) then; x = 1; end if).
        let mut pieces: Vec<&str> = Vec::new();
        let mut start = 0;
        for &semi_pos in &semicolons {
            pieces.push(trimmed[start..semi_pos].trim());
            start = semi_pos + 1;
        }
        pieces.push(trimmed[start..].trim());

        let _ = indent_str;
        let mut depth = 0i32;
        for stmt in pieces.into_iter().filter(|s| !s.is_empty()) {
            let kind = classify(stmt);
            let piece_depth = match kind {
                LineKind::FortranBlockClose | LineKind::FortranContinuation => depth - 1,
                _ => depth,
            };
            let piece_indent = (indent as i32 + piece_depth * indent_width as i32).max(0) as usize;
            result.push(format!("{}{}", " ".repeat(piece_indent), stmt));
            match kind {
                LineKind::FortranBlockOpen => depth += 1,
                LineKind::FortranBlockClose => depth -= 1,
                _ => {}
            }
        }
    }

    result
}

/// Align trailing `&` continuation markers at the column limit.
/// Lines ending with ` &` are padded so `&` sits at the column limit position.
fn align_ampersands(lines: &[String], col_limit: usize) -> Vec<String> {
    lines
        .iter()
        .map(|line| {
            let trimmed_start = line.trim_start();
            // Skip blank, comment, preprocessor, Fypp lines
            if trimmed_start.is_empty()
                || trimmed_start.starts_with('!')
                || trimmed_start.starts_with('#')
                || trimmed_start.starts_with("$:")
                || trimmed_start.starts_with("@:")
            {
                return line.clone();
            }

            // Check if line ends with " &"
            let trimmed_end = line.trim_end();
            if !trimmed_end.ends_with(" &") {
                return line.clone();
            }

            let current_len = trimmed_end.len();
            if current_len >= col_limit {
                return line.clone(); // already at or past limit
            }

            // Remove trailing " &", pad to col_limit - 1, then add "&"
            let code_part = trimmed_end[..trimmed_end.len() - 2].trim_end();
            let code_len = code_part.len();
            if code_len >= col_limit {
                return line.clone(); // code itself is too long
            }

            let padding = col_limit - 1 - code_len;
            format!("{}{}&", code_part, " ".repeat(padding))
        })
        .collect()
}

/// Align `=` signs across consecutive assignment statements.
/// Only aligns standalone `=` (not `==`, `/=`, `<=`, `>=`, `=>`, `::`).
fn align_assignments(lines: &[String], max_length: usize) -> Vec<String> {
    let mut result: Vec<String> = Vec::with_capacity(lines.len());
    let mut i = 0;

    while i < lines.len() {
        // Try to start a group of consecutive assignment lines at the SAME
        // indentation. Control statements that happen to contain `=` at
        // depth 0 (do-loop control, where one-liners) are not assignments.
        let group_start = i;
        let mut group: Vec<usize> = Vec::new(); // indices into lines
        let mut eq_columns: Vec<usize> = Vec::new(); // column of `=` in each line
        let mut group_indent: Option<usize> = None;

        while i < lines.len() {
            let first_word: String = lines[i]
                .trim_start()
                .chars()
                .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
                .collect::<String>()
                .to_ascii_lowercase();
            let is_control = matches!(
                first_word.as_str(),
                "do" | "if" | "where" | "forall" | "while" | "else" | "elseif" | "case" | "select"
            );
            let indent = leading_spaces(&lines[i]);
            let same_indent = group_indent.is_none_or(|gi| gi == indent);
            if !is_control && same_indent {
                if let Some(eq_col) = find_assignment_eq(&lines[i]) {
                    group.push(i);
                    eq_columns.push(eq_col);
                    group_indent = Some(indent);
                    i += 1;
                    continue;
                }
            }
            break;
        }

        if group.len() < 2 {
            // Not a group, emit line(s) unchanged
            for idx in group_start..i.max(group_start + 1) {
                if idx < lines.len() {
                    result.push(lines[idx].clone());
                }
            }
            if i == group_start {
                i += 1;
            }
            continue;
        }

        // Find the maximum `=` column in the group
        let max_eq_col = *eq_columns.iter().max().unwrap();

        // Align all lines in the group
        for (gi, &line_idx) in group.iter().enumerate() {
            let line = &lines[line_idx];
            let eq_col = eq_columns[gi];
            if eq_col == max_eq_col {
                result.push(line.clone());
            } else {
                let padding = max_eq_col - eq_col;
                // Insert padding before the `=`
                let before_eq = &line[..eq_col];
                let from_eq = &line[eq_col..];
                let new_line = format!(
                    "{}{}{}",
                    before_eq.trim_end(),
                    " ".repeat(padding + 1),
                    from_eq.trim_start()
                );
                // Check if alignment would exceed max_length
                if new_line.len() <= max_length {
                    result.push(new_line);
                } else {
                    result.push(line.clone());
                }
            }
        }
    }

    result
}

/// Find the column of a standalone `=` in an assignment statement.
/// Returns None if the line is not an assignment or is a comment/blank/etc.
/// Skips `==`, `/=`, `<=`, `>=`, `=>`, and `=` inside strings or parens.
fn find_assignment_eq(line: &str) -> Option<usize> {
    let trimmed = line.trim_start();
    // Skip blank, comment, preprocessor, Fypp, use, implicit lines
    if trimmed.is_empty()
        || trimmed.starts_with('!')
        || trimmed.starts_with('#')
        || trimmed.starts_with("$:")
        || trimmed.starts_with("@:")
    {
        return None;
    }

    let lower = trimmed.to_ascii_lowercase();
    // Skip declarations, use statements, etc.
    if lower.starts_with("use ") || lower.starts_with("implicit ") || lower.contains("::") {
        return None;
    }

    let bytes = line.as_bytes();
    let len = bytes.len();
    let mut in_string = false;
    let mut quote_char = b' ';
    let mut paren_depth = 0i32;

    let mut i = 0;
    while i < len {
        if in_string {
            if bytes[i] == quote_char {
                if i + 1 < len && bytes[i + 1] == quote_char {
                    i += 2; // escaped quote — stay in string
                    continue;
                }
                in_string = false;
            }
            i += 1;
            continue;
        }
        if bytes[i] == b'\'' || bytes[i] == b'"' {
            in_string = true;
            quote_char = bytes[i];
            i += 1;
            continue;
        }
        if bytes[i] == b'!' {
            break;
        }
        if bytes[i] == b'(' || bytes[i] == b'[' {
            paren_depth += 1;
        } else if bytes[i] == b')' || bytes[i] == b']' {
            paren_depth -= 1;
            if paren_depth < 0 {
                paren_depth = 0;
            }
        }

        if bytes[i] == b'=' && paren_depth == 0 {
            // Check it's not ==, /=, <=, >=, =>
            let prev = if i > 0 { bytes[i - 1] } else { b' ' };
            let next = if i + 1 < len { bytes[i + 1] } else { b' ' };
            if next == b'='
                || next == b'>'
                || prev == b'/'
                || prev == b'<'
                || prev == b'>'
                || prev == b'='
                || prev == b'!'
            {
                i += 1;
                continue;
            }
            return Some(i);
        }
        i += 1;
    }

    None
}

/// Reformat `use` statements with `only:` to have one item per line.
fn reformat_use_statements(lines: &[String], indent_width: usize) -> Vec<String> {
    let mut result: Vec<String> = Vec::new();

    for line in lines {
        let trimmed = line.trim_start();
        let lower = trimmed.to_ascii_lowercase();

        // Skip non-use lines, Fypp-conditional use statements
        if !lower.starts_with("use ")
            || trimmed.starts_with("#")
            || trimmed.starts_with("$:")
            || trimmed.starts_with("@:")
        {
            result.push(line.clone());
            continue;
        }

        // Split off any trailing comment FIRST: `only:` inside a comment is
        // text, and the comment must survive the reformatting.
        let (code_part, comment_part) = split_trailing_comment(trimmed);
        let code_part = code_part.trim_end();
        let comment_part = comment_part.trim_end();
        let code_lower = code_part.to_ascii_lowercase();

        // Check for `only:` in the CODE part only
        let only_pos = match code_lower.find("only:") {
            Some(pos) => pos,
            None => {
                result.push(line.clone());
                continue;
            }
        };

        // Handle single-line use statements only
        if code_part.ends_with('&') {
            result.push(line.clone());
            continue;
        }

        let indent = leading_spaces(line);
        let indent_str = " ".repeat(indent);
        let cont_indent = " ".repeat(indent + indent_width);

        // Extract part before `only:` and the import list after it
        let before_only = &code_part[..only_pos + 5]; // includes "only:"
        let imports_str = code_part[only_pos + 5..].trim();

        // Parse comma-separated imports
        let imports: Vec<&str> = imports_str
            .split(',')
            .map(|s| s.trim())
            .filter(|s| !s.is_empty())
            .collect();

        if imports.len() <= 1 {
            result.push(line.clone());
            continue;
        }

        // Emit reformatted: one import per line; a trailing comment is
        // reattached to the final line.
        result.push(format!("{}{} &", indent_str, before_only));
        for (idx, import) in imports.iter().enumerate() {
            if idx < imports.len() - 1 {
                result.push(format!("{}& {}, &", cont_indent, import));
            } else if comment_part.is_empty() {
                result.push(format!("{}& {}", cont_indent, import));
            } else {
                result.push(format!("{}& {}  {}", cont_indent, import, comment_part));
            }
        }
    }

    result
}

/// Normalize comma spacing: ensure exactly one space after each comma.
fn normalize_comma_spacing(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    let bytes = s.as_bytes();
    let len = bytes.len();
    let mut i = 0;
    // Start of the pending region to copy verbatim (always on a char boundary,
    // since we only cut at ASCII commas/spaces).
    let mut plain_start = 0;

    while i < len {
        if bytes[i] == b',' {
            result.push_str(&s[plain_start..i]);
            result.push_str(", ");
            // Skip any whitespace after the comma
            i += 1;
            while i < len && bytes[i] == b' ' {
                i += 1;
            }
            plain_start = i;
        } else {
            i += 1;
        }
    }
    result.push_str(&s[plain_start..]);

    result
}
