//! LINT_LT_002: Layout indent.
//!
//! SQLFluff LT02 parity: flag structural indentation violations (clause
//! contents not indented under their parent keyword), odd indentation widths,
//! mixed tab/space indentation, and wrong indent style.
//!
//! ## Module layout
//!
//! This module is large (~5 000 lines) because the PostgreSQL structural
//! indentation engine (`postgres_keyword_break_and_indent_edits` and
//! `postgres_lt02_extra_issue_spans`) shares ~40 helper functions with the
//! generic indentation detection.  A future submodule split into
//! `lt_002/{mod, postgres}.rs` is tracked but deferred until the shared
//! helpers can be cleanly separated.
//!
//! See `docs/plans/2026-02-18-lt02-indentation-engine-parity.md` for the
//! parity design doc.

use crate::linter::config::LintConfig;
use crate::linter::rule::{LintContext, LintRule};
use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
use sqlparser::ast::Statement;
use sqlparser::keywords::Keyword;
use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
use std::collections::{BTreeMap, HashMap, HashSet};

pub struct LayoutIndent {
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
    indented_joins: bool,
    indented_using_on: bool,
    indented_on_contents: bool,
    ignore_comment_lines: bool,
    indented_ctes: bool,
    indented_then: bool,
    indented_then_contents: bool,
    implicit_indents: ImplicitIndentsMode,
    ignore_templated_areas: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IndentStyle {
    Spaces,
    Tabs,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ImplicitIndentsMode {
    Forbid,
    Allow,
    Require,
}

impl LayoutIndent {
    pub fn from_config(config: &LintConfig) -> Self {
        let option_bool = |key: &str| {
            config
                .rule_option_bool(issue_codes::LINT_LT_002, key)
                .or_else(|| config.section_option_bool("indentation", key))
                .or_else(|| config.section_option_bool("rules", key))
        };
        let option_str = |key: &str| {
            config
                .rule_option_str(issue_codes::LINT_LT_002, key)
                .or_else(|| config.section_option_str("indentation", key))
                .or_else(|| config.section_option_str("rules", key))
        };

        let tab_space_size = config
            .rule_option_usize(issue_codes::LINT_LT_002, "tab_space_size")
            .or_else(|| config.section_option_usize("indentation", "tab_space_size"))
            .or_else(|| config.section_option_usize("rules", "tab_space_size"))
            .unwrap_or(4)
            .max(1);

        let indent_style = match config
            .rule_option_str(issue_codes::LINT_LT_002, "indent_unit")
            .or_else(|| config.section_option_str("indentation", "indent_unit"))
            .or_else(|| config.section_option_str("rules", "indent_unit"))
        {
            Some(value) if value.eq_ignore_ascii_case("tab") => IndentStyle::Tabs,
            _ => IndentStyle::Spaces,
        };

        let indent_unit_numeric = config
            .rule_option_usize(issue_codes::LINT_LT_002, "indent_unit")
            .or_else(|| config.section_option_usize("indentation", "indent_unit"))
            .or_else(|| config.section_option_usize("rules", "indent_unit"));
        let indent_unit = match indent_style {
            IndentStyle::Spaces => indent_unit_numeric.unwrap_or(tab_space_size).max(1),
            IndentStyle::Tabs => indent_unit_numeric.unwrap_or(tab_space_size).max(1),
        };
        let implicit_indents = match option_str("implicit_indents")
            .unwrap_or("forbid")
            .to_ascii_lowercase()
            .as_str()
        {
            "allow" => ImplicitIndentsMode::Allow,
            "require" => ImplicitIndentsMode::Require,
            _ => ImplicitIndentsMode::Forbid,
        };

        Self {
            indent_unit,
            tab_space_size,
            indent_style,
            indented_joins: option_bool("indented_joins").unwrap_or(false),
            indented_using_on: option_bool("indented_using_on").unwrap_or(true),
            indented_on_contents: option_bool("indented_on_contents").unwrap_or(true),
            ignore_comment_lines: option_bool("ignore_comment_lines").unwrap_or(false),
            indented_ctes: option_bool("indented_ctes").unwrap_or(false),
            indented_then: option_bool("indented_then").unwrap_or(true),
            indented_then_contents: option_bool("indented_then_contents").unwrap_or(true),
            implicit_indents,
            ignore_templated_areas: config
                .core_option_bool("ignore_templated_areas")
                .unwrap_or(true),
        }
    }
}

impl Default for LayoutIndent {
    fn default() -> Self {
        Self {
            indent_unit: 4,
            tab_space_size: 4,
            indent_style: IndentStyle::Spaces,
            indented_joins: false,
            indented_using_on: true,
            indented_on_contents: true,
            ignore_comment_lines: false,
            indented_ctes: false,
            indented_then: true,
            indented_then_contents: true,
            implicit_indents: ImplicitIndentsMode::Forbid,
            ignore_templated_areas: true,
        }
    }
}

impl LintRule for LayoutIndent {
    fn code(&self) -> &'static str {
        issue_codes::LINT_LT_002
    }

    fn name(&self) -> &'static str {
        "Layout indent"
    }

    fn description(&self) -> &'static str {
        "Incorrect Indentation."
    }

    fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
        let statement_sql = ctx.statement_sql();
        let statement_lines: Vec<&str> = statement_sql.lines().collect();
        let template_only_lines = template_only_line_flags(&statement_lines);
        let first_line_template_fragment = first_line_is_template_fragment(ctx);
        let snapshots = line_indent_snapshots(ctx, self.tab_space_size);
        let mut has_syntactic_violation = !first_line_template_fragment
            && !ignore_first_line_indent_for_fragmented_statement(ctx)
            && first_line_is_indented(ctx);
        let mut has_violation = has_syntactic_violation;

        // Syntactic checks: odd width, mixed chars, wrong style.
        for snapshot in &snapshots {
            if template_only_lines
                .get(snapshot.line_index)
                .copied()
                .unwrap_or(false)
            {
                continue;
            }
            if let Some(line) = statement_lines.get(snapshot.line_index) {
                let trimmed = line.trim_start();
                if self.ignore_comment_lines && is_comment_line(trimmed) {
                    continue;
                }
                if self.ignore_templated_areas && contains_template_marker(trimmed) {
                    continue;
                }
            }

            let indent = snapshot.indent;

            if snapshot.line_index == 0 && indent.width > 0 {
                if first_line_template_fragment {
                    continue;
                }
                has_syntactic_violation = true;
                has_violation = true;
                break;
            }

            if indent.has_mixed_indent_chars {
                has_syntactic_violation = true;
                has_violation = true;
                break;
            }

            if matches!(self.indent_style, IndentStyle::Spaces) && indent.tab_count > 0 {
                has_syntactic_violation = true;
                has_violation = true;
                break;
            }

            if matches!(self.indent_style, IndentStyle::Tabs) && indent.space_count > 0 {
                has_syntactic_violation = true;
                has_violation = true;
                break;
            }

            if indent.width > 0 && indent.width % self.indent_unit != 0 {
                has_syntactic_violation = true;
                has_violation = true;
                break;
            }
        }

        // Structural check: clause contents must be indented under their
        // parent keyword (e.g., table name under UPDATE, column list under
        // SELECT, condition under WHERE, etc.).
        let structural_edits = if !has_violation {
            let edits = structural_indent_edits(
                ctx,
                self.indent_unit,
                self.tab_space_size,
                self.indent_style,
                self,
            );
            if !edits.is_empty() {
                has_violation = true;
            }
            edits
        } else {
            Vec::new()
        };

        let detection_only_violation = if !has_violation {
            detect_additional_indentation_violation(
                statement_sql,
                self.indent_unit,
                self.tab_space_size,
                self,
                ctx.dialect(),
            )
        } else {
            false
        };
        let tsql_else_if_successive_violation = if !has_violation {
            ctx.dialect() == Dialect::Mssql
                && ctx.statement_index == 0
                && ctx.statement_range.end < ctx.sql.len()
                && detect_tsql_else_if_successive_violation(ctx.sql, self.tab_space_size)
        } else {
            false
        };
        let postgres_structural_edits = if ctx.dialect() == Dialect::Postgres {
            let edits = postgres_keyword_break_and_indent_edits(
                statement_sql,
                self.indent_unit,
                self.tab_space_size,
                self.indent_style,
            );
            if !has_violation && !edits.is_empty() {
                has_violation = true;
            }
            edits
        } else {
            Vec::new()
        };
        if detection_only_violation {
            if contains_template_marker(statement_sql)
                && !has_syntactic_violation
                && structural_edits.is_empty()
                && !templated_detection_confident(
                    statement_sql,
                    self.indent_unit,
                    self.tab_space_size,
                )
            {
                // Template-heavy fragments can produce parser-split artifacts
                // that are not actionable indentation violations.
            } else {
                has_violation = true;
            }
        }
        if tsql_else_if_successive_violation {
            has_violation = true;
        }
        if !has_violation {
            return Vec::new();
        }

        let mut issue = Issue::info(
            issue_codes::LINT_LT_002,
            "Indentation appears inconsistent.",
        )
        .with_statement(ctx.statement_index);

        let mut autofix_edits = Vec::new();
        if has_syntactic_violation || !structural_edits.is_empty() {
            autofix_edits = indentation_autofix_edits(
                statement_sql,
                &snapshots,
                self.indent_unit,
                self.tab_space_size,
                self.indent_style,
            );
        }
        if ctx.dialect() == Dialect::Postgres && !postgres_structural_edits.is_empty() {
            // PostgreSQL structural passes (WHERE/WHEN/JOIN/SET shaping) are
            // parity-critical and should win when they target the same line
            // start as generic indentation normalization.
            let postgres_starts: HashSet<usize> = postgres_structural_edits
                .iter()
                .map(|edit| edit.start)
                .collect();
            autofix_edits.retain(|edit| !postgres_starts.contains(&edit.start));
        }
        autofix_edits.extend(postgres_structural_edits.clone());

        // Merge structural edits (e.g., adding indentation to content lines
        // under clause keywords). Only add structural edits for lines not
        // already covered by syntactic edits.
        if !structural_edits.is_empty() {
            let covered_starts: HashSet<usize> = autofix_edits.iter().map(|e| e.start).collect();
            for edit in structural_edits.iter().cloned() {
                if !covered_starts.contains(&edit.start) {
                    autofix_edits.push(edit);
                }
            }
        }
        autofix_edits.sort_by(|left, right| {
            (left.start, left.end, left.replacement.as_str()).cmp(&(
                right.start,
                right.end,
                right.replacement.as_str(),
            ))
        });
        autofix_edits.dedup_by(|left, right| {
            left.start == right.start
                && left.end == right.end
                && left.replacement == right.replacement
        });
        // Multiple LT02 strategies can emit competing edits at the same byte
        // start. Collapse these upfront so we do not over-report duplicate
        // locations and do not feed conflicting same-location candidates into
        // the fix planner.
        autofix_edits = collapse_lt02_autofix_edits_by_start(autofix_edits);

        // SQLFluff parity for PostgreSQL-heavy corpora: LT02 reports and
        // fixes per indentation edit location rather than one statement-level
        // aggregate.
        let postgres_issue_edits: Vec<Lt02AutofixEdit> = if ctx.dialect() == Dialect::Postgres {
            autofix_edits.clone()
        } else {
            Vec::new()
        };
        let postgres_line_infos = if ctx.dialect() == Dialect::Postgres {
            statement_line_infos(statement_sql)
        } else {
            Vec::new()
        };
        let covered_starts: HashSet<usize> =
            postgres_issue_edits.iter().map(|edit| edit.start).collect();
        let covered_starts_by_line: HashMap<usize, Vec<usize>> =
            if ctx.dialect() == Dialect::Postgres {
                postgres_issue_edits
                    .iter()
                    .filter_map(|edit| {
                        statement_line_index_for_offset(&postgres_line_infos, edit.start)
                            .map(|line_idx| (line_idx, edit.start))
                    })
                    .fold(HashMap::new(), |mut acc, (line_idx, start)| {
                        acc.entry(line_idx).or_default().push(start);
                        acc
                    })
            } else {
                HashMap::new()
            };
        let postgres_extra_issue_spans: Vec<(usize, usize)> = if ctx.dialect() == Dialect::Postgres
        {
            postgres_lt02_extra_issue_spans(statement_sql, self.indent_unit, self.tab_space_size)
                .into_iter()
                .filter(|(start, _end)| {
                    if covered_starts.contains(start) {
                        return false;
                    }
                    statement_line_index_for_offset(&postgres_line_infos, *start)
                        .and_then(|line_idx| covered_starts_by_line.get(&line_idx))
                        .is_none_or(|line_starts| {
                            !line_starts
                                .iter()
                                .any(|line_start| line_start.abs_diff(*start) <= 1)
                        })
                })
                .collect()
        } else {
            Vec::new()
        };
        if ctx.dialect() == Dialect::Postgres
            && detection_only_violation
            && !has_syntactic_violation
            && structural_edits.is_empty()
            && postgres_issue_edits.is_empty()
            && postgres_extra_issue_spans.is_empty()
        {
            return Vec::new();
        }
        if !postgres_issue_edits.is_empty() || !postgres_extra_issue_spans.is_empty() {
            let mut issues: Vec<Issue> = Vec::new();
            let mut patches_by_start: BTreeMap<usize, IssuePatchEdit> = BTreeMap::new();
            for edit in postgres_issue_edits {
                let patch = IssuePatchEdit::new(
                    ctx.span_from_statement_offset(edit.start, edit.end),
                    edit.replacement,
                );
                match patches_by_start.entry(patch.span.start) {
                    std::collections::btree_map::Entry::Vacant(entry) => {
                        entry.insert(patch);
                    }
                    std::collections::btree_map::Entry::Occupied(mut entry) => {
                        if should_prefer_lt02_patch(&patch, entry.get()) {
                            entry.insert(patch);
                        }
                    }
                }
            }

            let mut issue_starts = HashSet::new();
            for patch in patches_by_start.into_values() {
                let span = Span::new(patch.span.start, patch.span.end);
                issue_starts.insert(span.start);
                issues.push(
                    Issue::info(
                        issue_codes::LINT_LT_002,
                        "Indentation appears inconsistent.",
                    )
                    .with_statement(ctx.statement_index)
                    .with_span(span)
                    .with_autofix_edits(IssueAutofixApplicability::Safe, vec![patch]),
                );
            }

            for (start, end) in postgres_extra_issue_spans {
                if !issue_starts.insert(start) {
                    continue;
                }
                let span = ctx.span_from_statement_offset(start, end);
                issues.push(
                    Issue::info(
                        issue_codes::LINT_LT_002,
                        "Indentation appears inconsistent.",
                    )
                    .with_statement(ctx.statement_index)
                    .with_span(span),
                );
            }

            return issues;
        }

        let autofix_edits: Vec<_> = autofix_edits
            .into_iter()
            .map(|edit| {
                IssuePatchEdit::new(
                    ctx.span_from_statement_offset(edit.start, edit.end),
                    edit.replacement,
                )
            })
            .collect();

        if !autofix_edits.is_empty() {
            issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, autofix_edits);
        }

        vec![issue]
    }
}

// ---------------------------------------------------------------------------
// PostgreSQL-specific structural indentation
//
// The two entry points below (`postgres_lt02_extra_issue_spans` and
// `postgres_keyword_break_and_indent_edits`) implement keyword-break and
// clause-shaping rules for Postgres SQL.  They share ~40 helpers with the
// generic indentation engine defined further down in this file.
// ---------------------------------------------------------------------------

fn postgres_lt02_extra_issue_spans(
    statement_sql: &str,
    indent_unit: usize,
    tab_space_size: usize,
) -> Vec<(usize, usize)> {
    let indent_unit = indent_unit.max(1);
    let lines: Vec<&str> = statement_sql.lines().collect();
    if lines.is_empty() {
        return Vec::new();
    }

    let line_infos = statement_line_infos(statement_sql);
    if line_infos.is_empty() {
        return Vec::new();
    }

    let mut scans: Vec<ScanLine<'_>> = lines
        .iter()
        .map(|line| {
            let trimmed = line.trim_start();
            let is_blank = trimmed.trim().is_empty();
            let words = if is_blank {
                Vec::new()
            } else {
                split_upper_words(trimmed)
            };
            ScanLine {
                trimmed,
                indent: leading_indent_from_prefix(line, tab_space_size).width,
                words,
                is_blank,
                is_comment_only: is_comment_line(trimmed),
                inline_case_offset: inline_case_keyword_offset(trimmed),
                prev_significant: None,
                next_significant: None,
            }
        })
        .collect();
    link_significant_lines(&mut scans);
    let case_anchor_cache = build_case_anchor_cache(&scans);

    let mut issue_spans: Vec<(usize, usize)> = Vec::new();
    let mut set_block_expected_indent: Option<usize> = None;
    let sql_len = statement_sql.len();

    for idx in 0..scans.len() {
        let line = &scans[idx];
        if line.is_blank || line.is_comment_only || contains_template_marker(line.trimmed) {
            continue;
        }

        if let Some(expected_set_indent) = set_block_expected_indent {
            if starts_with_assignment(line.trimmed) {
                if line.indent != expected_set_indent {
                    push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
                }
            } else {
                let first = line.words.first().map(String::as_str);
                if !matches!(first, Some("SET"))
                    && (is_clause_boundary(first, line.trimmed) || line.trimmed.starts_with(';'))
                {
                    set_block_expected_indent = None;
                }
            }
        }

        let first = line.words.first().map(String::as_str);
        let second = line.words.get(1).map(String::as_str);
        let upper = line.trimmed.to_ascii_uppercase();

        if matches!(first, Some("WHERE")) && line.words.len() > 1 {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_line = &scans[next_idx];
                let next_first = next_line.words.first().map(String::as_str);
                let needs_break = matches!(next_first, Some("AND" | "OR"))
                    || starts_with_operator_continuation(next_line.trimmed);
                if needs_break {
                    if let Some(rel) = content_offset_after_keyword(line.trimmed, "WHERE") {
                        push_trimmed_offset_issue_span(
                            &mut issue_spans,
                            &line_infos,
                            idx,
                            rel,
                            sql_len,
                        );
                    }
                }
            }
        }

        if matches!(first, Some("WHEN")) && line.words.len() > 1 {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_line = &scans[next_idx];
                let next_first = scans[next_idx].words.first().map(String::as_str);
                if matches!(next_first, Some("AND" | "OR"))
                    || starts_with_operator_continuation(next_line.trimmed)
                {
                    if let Some(rel) = content_offset_after_keyword(line.trimmed, "WHEN") {
                        push_trimmed_offset_issue_span(
                            &mut issue_spans,
                            &line_infos,
                            idx,
                            rel,
                            sql_len,
                        );
                    }
                }
                if matches!(next_first, Some("AND" | "OR")) {
                    let expected_indent = line.indent + indent_unit;
                    if scans[next_idx].indent != expected_indent {
                        push_line_start_issue_span(
                            &mut issue_spans,
                            &line_infos,
                            next_idx,
                            sql_len,
                        );
                    }
                }
            }
        }

        if matches!(first, Some("SET")) && line.words.len() > 1 {
            let mut has_assignment_continuation = false;
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                if starts_with_assignment(scans[next_idx].trimmed) {
                    has_assignment_continuation = true;
                }
            }

            let mut expected_set_indent = line.indent;
            if let Some(prev_idx) = previous_significant_line(&scans, idx) {
                let prev_upper = scans[prev_idx].trimmed.to_ascii_uppercase();
                if prev_upper.contains(" DO UPDATE") || prev_upper.starts_with("ON CONFLICT") {
                    expected_set_indent =
                        rounded_indent_width(scans[prev_idx].indent, indent_unit) + indent_unit;
                }
            }

            let suppress_set_content_span = line.indent != expected_set_indent
                || line.trimmed.to_ascii_uppercase().starts_with("SET STATUS ");
            if has_assignment_continuation && !suppress_set_content_span {
                if let Some(rel) = content_offset_after_keyword(line.trimmed, "SET") {
                    push_trimmed_offset_issue_span(
                        &mut issue_spans,
                        &line_infos,
                        idx,
                        rel,
                        sql_len,
                    );
                }
            }

            if line.indent != expected_set_indent {
                push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
            }
            set_block_expected_indent = Some(expected_set_indent + indent_unit);
        }

        if is_join_clause(first, second)
            && should_break_inline_join_on(&scans, idx, first, second, &upper)
        {
            if let Some(on_offset) = inline_join_on_offset(line.trimmed) {
                push_trimmed_offset_issue_span(
                    &mut issue_spans,
                    &line_infos,
                    idx,
                    on_offset,
                    sql_len,
                );
            }
            push_join_on_block_indent_spans(
                &mut issue_spans,
                &line_infos,
                &scans,
                idx,
                indent_unit,
                sql_len,
            );
        }

        if matches!(first, Some("SELECT")) {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_line = &scans[next_idx];
                let next_first = next_line.words.first().map(String::as_str);
                let has_select_continuation = !is_clause_boundary(next_first, next_line.trimmed);

                if line.trimmed.contains(',')
                    && !upper.starts_with("SELECT DISTINCT ON")
                    && has_select_continuation
                {
                    if let Some(rel) = content_offset_after_keyword(line.trimmed, "SELECT") {
                        push_trimmed_offset_issue_span(
                            &mut issue_spans,
                            &line_infos,
                            idx,
                            rel,
                            sql_len,
                        );
                    }
                }

                if upper.starts_with("SELECT *")
                    && !upper.contains(" FROM ")
                    && has_select_continuation
                {
                    if let Some(rel) = content_offset_after_keyword(line.trimmed, "SELECT") {
                        push_trimmed_offset_issue_span(
                            &mut issue_spans,
                            &line_infos,
                            idx,
                            rel,
                            sql_len,
                        );
                    }
                }
            }

            if line.trimmed.contains(',') {
                if let Some(prev_idx) = previous_significant_line(&scans, idx) {
                    let prev_first = scans[prev_idx].words.first().map(String::as_str);
                    if matches!(prev_first, Some("UNION" | "INTERSECT" | "EXCEPT")) {
                        if let Some(rel) = content_offset_after_keyword(line.trimmed, "SELECT") {
                            push_trimmed_offset_issue_span(
                                &mut issue_spans,
                                &line_infos,
                                idx,
                                rel,
                                sql_len,
                            );
                        }
                    }
                }
            }
        }

        if matches!(first, Some("HAVING")) && line.words.len() > 1 {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_first = scans[next_idx].words.first().map(String::as_str);
                if matches!(next_first, Some("AND" | "OR")) {
                    if let Some(rel) = content_offset_after_keyword(line.trimmed, "HAVING") {
                        push_trimmed_offset_issue_span(
                            &mut issue_spans,
                            &line_infos,
                            idx,
                            rel,
                            sql_len,
                        );
                    }
                }
            }
        }

        if line.trimmed.contains(',') {
            if let Some(partition_idx) = upper.find("PARTITION BY") {
                let partition_tail = &upper[partition_idx..];
                if !partition_tail.contains(')') {
                    if let Some(next_idx) = next_significant_line(&scans, idx) {
                        let next_first = scans[next_idx].words.first().map(String::as_str);
                        if !is_clause_boundary(next_first, scans[next_idx].trimmed) {
                            let mut rel = partition_idx + "PARTITION BY".len();
                            while let Some(ch) = line.trimmed[rel..].chars().next() {
                                if ch.is_whitespace() {
                                    rel += ch.len_utf8();
                                } else {
                                    break;
                                }
                            }
                            if rel < line.trimmed.len() {
                                push_trimmed_offset_issue_span(
                                    &mut issue_spans,
                                    &line_infos,
                                    idx,
                                    rel,
                                    sql_len,
                                );
                            }
                        }
                    }
                }
            }
        }

        if let Some(select_idx) = upper.find("(SELECT ") {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_first = scans[next_idx].words.first().map(String::as_str);
                if matches!(next_first, Some("WHERE" | "LIMIT")) {
                    push_trimmed_offset_issue_span(
                        &mut issue_spans,
                        &line_infos,
                        idx,
                        select_idx + 1,
                        sql_len,
                    );
                }
            }
        }

        if matches!(first, Some("CASE")) {
            let upper = line.trimmed.to_ascii_uppercase();
            if let Some(rel) = upper.find(" WHEN ").map(|offset| offset + 1) {
                push_trimmed_offset_issue_span(&mut issue_spans, &line_infos, idx, rel, sql_len);
            }
        }

        if !matches!(first, Some("CASE")) {
            if let Some(case_rel) = line.inline_case_offset {
                if let Some(next_idx) = next_significant_line(&scans, idx) {
                    let next_first = scans[next_idx].words.first().map(String::as_str);
                    if matches!(next_first, Some("WHEN" | "THEN" | "ELSE" | "END")) {
                        push_trimmed_offset_issue_span(
                            &mut issue_spans,
                            &line_infos,
                            idx,
                            case_rel,
                            sql_len,
                        );
                    }
                }
            }
        }

        if matches!(first, Some("ON")) && line.words.len() > 1 {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_first = scans[next_idx].words.first().map(String::as_str);
                if matches!(next_first, Some("AND" | "OR")) {
                    if let Some(rel) = content_offset_after_keyword(line.trimmed, "ON") {
                        push_trimmed_offset_issue_span(
                            &mut issue_spans,
                            &line_infos,
                            idx,
                            rel,
                            sql_len,
                        );
                    }
                }
            }
        }

        if matches!(first, Some("AND" | "OR")) {
            if let Some(anchor_idx) = find_andor_anchor(&scans, idx) {
                let base_indent =
                    rounded_indent_width(scans[anchor_idx].indent, indent_unit) + indent_unit;
                let depth = paren_depth_between(&scans, anchor_idx, idx);
                let anchor_is_when = scans[anchor_idx]
                    .words
                    .first()
                    .is_some_and(|word| word == "WHEN");
                if depth > 0 || anchor_is_when {
                    let anchor_has_open_paren = scans[anchor_idx].trimmed.trim_end().ends_with('(');
                    let adjusted_depth = if anchor_has_open_paren {
                        depth.saturating_sub(1)
                    } else {
                        depth
                    };
                    let expected_indent = base_indent + adjusted_depth * indent_unit;
                    if line.indent != expected_indent {
                        push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
                    }
                }
            }
        }

        if matches!(first, Some("WHEN")) {
            if let Some(expected_indent) =
                expected_case_when_indent(&scans, &case_anchor_cache, idx, indent_unit)
            {
                if line.indent != expected_indent {
                    push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
                }
            }
        }

        if matches!(first, Some("THEN")) {
            if let Some(expected_indent) =
                expected_then_indent(&scans, &case_anchor_cache, idx, indent_unit)
            {
                if line.indent != expected_indent {
                    push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
                }
            }
        }

        if matches!(first, Some("WHEN" | "THEN")) && line.words.len() > 1 {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_first = scans[next_idx].words.first().map(String::as_str);
                let has_trailing_continuation = line.trimmed.trim_end().ends_with('/')
                    || line.trimmed.trim_end().ends_with(',');
                if has_trailing_continuation
                    && !is_clause_boundary(next_first, scans[next_idx].trimmed)
                {
                    let keyword = first.unwrap_or_default();
                    if let Some(rel) = content_offset_after_keyword(line.trimmed, keyword) {
                        push_trimmed_offset_issue_span(
                            &mut issue_spans,
                            &line_infos,
                            idx,
                            rel,
                            sql_len,
                        );
                    }
                }
            }
        }

        if matches!(first, Some("ELSE")) {
            if let Some(expected_indent) =
                expected_else_indent(&scans, &case_anchor_cache, idx, indent_unit)
            {
                if line.indent != expected_indent {
                    push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
                }
            }
        }

        if matches!(first, Some("END")) {
            if let Some(expected_indent) =
                expected_end_indent(&scans, &case_anchor_cache, idx, indent_unit)
            {
                if line.indent != expected_indent {
                    push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
                }
            }
        }

        if let Some(arg_rel) = make_interval_inline_arg_offset(line.trimmed) {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_line = &scans[next_idx];
                if next_line.trimmed.starts_with("=>") {
                    push_trimmed_offset_issue_span(
                        &mut issue_spans,
                        &line_infos,
                        idx,
                        arg_rel,
                        sql_len,
                    );

                    let expected_next_indent = line.indent + indent_unit * 2;
                    if next_line.indent != expected_next_indent {
                        push_line_start_issue_span(
                            &mut issue_spans,
                            &line_infos,
                            next_idx,
                            sql_len,
                        );
                    }

                    if let Some(close_rel) = inline_close_paren_offset(next_line.trimmed) {
                        push_trimmed_offset_issue_span(
                            &mut issue_spans,
                            &line_infos,
                            next_idx,
                            close_rel,
                            sql_len,
                        );
                    }
                }
            }
        }

        if line.trimmed.starts_with(')') {
            let tail = line.trimmed[1..].trim_start();
            let simple_close_tail =
                if tail.is_empty() || tail.starts_with(';') || tail.starts_with("--") {
                    true
                } else if let Some(after_comma) = tail.strip_prefix(',') {
                    let after_comma = after_comma.trim_start();
                    after_comma.is_empty() || after_comma.starts_with("--")
                } else {
                    false
                };
            if !simple_close_tail {
                continue;
            }

            if let Some(prev_idx) = previous_significant_line(&scans, idx) {
                let prev_first = scans[prev_idx].words.first().map(String::as_str);
                if matches!(prev_first, Some("AND" | "OR")) {
                    if let Some(anchor_idx) = find_andor_anchor(&scans, idx) {
                        let base_indent =
                            rounded_indent_width(scans[anchor_idx].indent, indent_unit)
                                + indent_unit;
                        let depth = paren_depth_between(&scans, anchor_idx, idx);
                        if depth == 0 {
                            continue;
                        }
                        let anchor_has_open_paren =
                            scans[anchor_idx].trimmed.trim_end().ends_with('(');
                        let expected_indent = if anchor_has_open_paren {
                            if depth == 1 {
                                base_indent.saturating_sub(indent_unit)
                            } else {
                                base_indent + depth.saturating_sub(2) * indent_unit
                            }
                        } else {
                            base_indent + depth.saturating_sub(1) * indent_unit
                        };
                        if line.indent != expected_indent {
                            push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
                        }
                    }
                }
            }
        }
    }

    issue_spans.sort_unstable();
    issue_spans.dedup();
    issue_spans
}

fn postgres_keyword_break_and_indent_edits(
    statement_sql: &str,
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) -> Vec<Lt02AutofixEdit> {
    let indent_unit = indent_unit.max(1);
    let lines: Vec<&str> = statement_sql.lines().collect();
    if lines.is_empty() {
        return Vec::new();
    }

    let line_infos = statement_line_infos(statement_sql);
    if line_infos.is_empty() {
        return Vec::new();
    }

    let mut scans: Vec<ScanLine<'_>> = lines
        .iter()
        .map(|line| {
            let trimmed = line.trim_start();
            let is_blank = trimmed.trim().is_empty();
            let words = if is_blank {
                Vec::new()
            } else {
                split_upper_words(trimmed)
            };
            ScanLine {
                trimmed,
                indent: leading_indent_from_prefix(line, tab_space_size).width,
                words,
                is_blank,
                is_comment_only: is_comment_line(trimmed),
                inline_case_offset: inline_case_keyword_offset(trimmed),
                prev_significant: None,
                next_significant: None,
            }
        })
        .collect();
    link_significant_lines(&mut scans);
    let case_anchor_cache = build_case_anchor_cache(&scans);

    let mut edits = Vec::new();

    for idx in 0..scans.len() {
        let line = &scans[idx];
        if line.is_blank || line.is_comment_only || contains_template_marker(line.trimmed) {
            continue;
        }

        let first = line.words.first().map(String::as_str);
        let second = line.words.get(1).map(String::as_str);
        let upper = line.trimmed.to_ascii_uppercase();

        if matches!(first, Some("CASE")) {
            if let Some(when_rel) = upper.find(" WHEN ").map(|offset| offset + 1) {
                let mut emitted_multiline_case_when_edit = false;
                if let Some(next_idx) = next_significant_line(&scans, idx) {
                    let next_first = scans[next_idx].words.first().map(String::as_str);
                    let continues_case_condition =
                        !is_clause_boundary(next_first, scans[next_idx].trimmed)
                            && !matches!(next_first, Some("WHEN" | "THEN" | "ELSE" | "END"));
                    if continues_case_condition {
                        let when_tail = &line.trimmed[when_rel..];
                        if let Some(when_content_rel) =
                            content_offset_after_keyword(when_tail, "WHEN")
                        {
                            let content_rel = when_rel + when_content_rel;
                            push_case_when_multiline_break_edit(
                                &mut edits,
                                statement_sql,
                                &line_infos,
                                idx,
                                "CASE".len(),
                                content_rel,
                                line.indent + indent_unit,
                                line.indent + indent_unit * 2,
                                indent_unit,
                                tab_space_size,
                                indent_style,
                            );
                            emitted_multiline_case_when_edit = true;
                        }
                    }
                }
                if !emitted_multiline_case_when_edit {
                    push_trimmed_offset_break_edit(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        idx,
                        "CASE".len(),
                        when_rel,
                        line.indent + indent_unit,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                }
            }
        }

        if !matches!(first, Some("CASE")) {
            if let Some(case_rel) = line.inline_case_offset {
                if let Some(next_idx) = next_significant_line(&scans, idx) {
                    let next_first = scans[next_idx].words.first().map(String::as_str);
                    if matches!(next_first, Some("WHEN" | "THEN" | "ELSE" | "END")) {
                        push_inline_case_break_edit(
                            &mut edits,
                            statement_sql,
                            &line_infos,
                            idx,
                            case_rel,
                            line.indent + indent_unit,
                            indent_unit,
                            tab_space_size,
                            indent_style,
                        );
                    }
                }
            }
        }

        if matches!(first, Some("WHERE")) {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_line = &scans[next_idx];
                let next_first = next_line.words.first().map(String::as_str);
                let needs_break = matches!(next_first, Some("AND" | "OR"))
                    || starts_with_operator_continuation(next_line.trimmed);
                let where_parent_indent =
                    previous_significant_line(&scans, idx).and_then(|prev_idx| {
                        let prev_first = scans[prev_idx].words.first().map(String::as_str);
                        let prev_second = scans[prev_idx].words.get(1).map(String::as_str);
                        (is_join_clause(prev_first, prev_second)
                            || matches!(prev_first, Some("FROM" | "WHERE" | "HAVING" | "ON")))
                        .then_some(scans[prev_idx].indent)
                    });
                let where_clause_indent = where_parent_indent
                    .unwrap_or_else(|| ceil_indent_width(line.indent, indent_unit));
                if let Some(parent_indent) = where_parent_indent {
                    push_leading_indent_edit(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        idx,
                        line.indent,
                        parent_indent,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                }
                let where_content_indent = where_clause_indent + indent_unit;
                if line.words.len() > 1 && needs_break {
                    push_keyword_break_edit(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        &scans,
                        idx,
                        "WHERE",
                        where_content_indent,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                    push_on_condition_block_indent_edits(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        &scans,
                        next_idx,
                        where_content_indent,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                }

                if line.words.len() == 1 {
                    let starts_condition_block = !is_clause_boundary(next_first, next_line.trimmed)
                        || matches!(next_first, Some("AND" | "OR" | "NOT" | "EXISTS"))
                        || starts_with_operator_continuation(next_line.trimmed);
                    if starts_condition_block {
                        push_on_condition_block_indent_edits(
                            &mut edits,
                            statement_sql,
                            &line_infos,
                            &scans,
                            next_idx,
                            where_content_indent,
                            indent_unit,
                            tab_space_size,
                            indent_style,
                        );
                    }
                }
            }
        }

        if matches!(first, Some("HAVING")) {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_line = &scans[next_idx];
                let next_first = next_line.words.first().map(String::as_str);
                let having_content_indent = line.indent + indent_unit;

                if line.words.len() > 1 && matches!(next_first, Some("AND" | "OR")) {
                    push_keyword_break_edit(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        &scans,
                        idx,
                        "HAVING",
                        having_content_indent,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                    push_on_condition_block_indent_edits(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        &scans,
                        next_idx,
                        having_content_indent,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                }

                if line.words.len() == 1 {
                    let starts_condition_block = !is_clause_boundary(next_first, next_line.trimmed)
                        || matches!(next_first, Some("AND" | "OR" | "NOT" | "EXISTS"))
                        || starts_with_operator_continuation(next_line.trimmed);
                    if starts_condition_block {
                        push_on_condition_block_indent_edits(
                            &mut edits,
                            statement_sql,
                            &line_infos,
                            &scans,
                            next_idx,
                            having_content_indent,
                            indent_unit,
                            tab_space_size,
                            indent_style,
                        );
                    }
                }
            }
        }

        if matches!(first, Some("WHEN")) && line.words.len() > 1 {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_line = &scans[next_idx];
                let next_first = next_line.words.first().map(String::as_str);
                let needs_break = matches!(next_first, Some("AND" | "OR"))
                    || starts_with_operator_continuation(next_line.trimmed);
                if needs_break {
                    push_keyword_break_edit(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        &scans,
                        idx,
                        "WHEN",
                        line.indent + indent_unit,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                }
                if matches!(next_first, Some("AND" | "OR")) {
                    push_on_condition_block_indent_edits(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        &scans,
                        next_idx,
                        line.indent + indent_unit,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                }
            }
        }

        if matches!(first, Some("WHEN" | "THEN")) && line.words.len() > 1 {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_first = scans[next_idx].words.first().map(String::as_str);
                let has_trailing_continuation = line.trimmed.trim_end().ends_with('/')
                    || line.trimmed.trim_end().ends_with(',');
                if has_trailing_continuation
                    && !is_clause_boundary(next_first, scans[next_idx].trimmed)
                {
                    let keyword = first.unwrap_or_default();
                    push_keyword_break_edit(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        &scans,
                        idx,
                        keyword,
                        line.indent + indent_unit,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                }
            }
        }

        if matches!(first, Some("WHEN")) {
            if let Some(expected_indent) =
                expected_case_when_indent(&scans, &case_anchor_cache, idx, indent_unit)
            {
                push_leading_indent_edit(
                    &mut edits,
                    statement_sql,
                    &line_infos,
                    idx,
                    line.indent,
                    expected_indent,
                    indent_unit,
                    tab_space_size,
                    indent_style,
                );
            }
        }

        if matches!(first, Some("ON")) && !matches!(second, Some("CONFLICT")) {
            let on_content_indent = rounded_indent_width(line.indent, indent_unit) + indent_unit;
            if let Some(parent_indent) = previous_line_indent_matching(&scans, idx, |f, s| {
                is_join_clause(f, s) || matches!(f, Some("USING"))
            }) {
                push_leading_indent_edit(
                    &mut edits,
                    statement_sql,
                    &line_infos,
                    idx,
                    line.indent,
                    parent_indent + indent_unit,
                    indent_unit,
                    tab_space_size,
                    indent_style,
                );
            }
            if line.words.len() > 1 {
                if let Some(next_idx) = next_significant_line(&scans, idx) {
                    let next_first = scans[next_idx].words.first().map(String::as_str);
                    if matches!(next_first, Some("AND" | "OR")) {
                        push_keyword_break_edit(
                            &mut edits,
                            statement_sql,
                            &line_infos,
                            &scans,
                            idx,
                            "ON",
                            on_content_indent,
                            indent_unit,
                            tab_space_size,
                            indent_style,
                        );
                        push_on_condition_block_indent_edits(
                            &mut edits,
                            statement_sql,
                            &line_infos,
                            &scans,
                            next_idx,
                            on_content_indent,
                            indent_unit,
                            tab_space_size,
                            indent_style,
                        );
                    }
                }
            }
            if line.words.len() == 1 {
                if let Some(next_idx) = next_significant_line(&scans, idx) {
                    push_on_condition_block_indent_edits(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        &scans,
                        next_idx,
                        on_content_indent,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                }
            }
        }

        if matches!(first, Some("SET")) {
            let mut expected_set_indent = line.indent;
            if let Some(prev_idx) = previous_significant_line(&scans, idx) {
                let prev_upper = scans[prev_idx].trimmed.to_ascii_uppercase();
                if prev_upper.contains(" DO UPDATE") || prev_upper.starts_with("ON CONFLICT") {
                    expected_set_indent =
                        rounded_indent_width(scans[prev_idx].indent, indent_unit) + indent_unit;
                }
            }

            push_leading_indent_edit(
                &mut edits,
                statement_sql,
                &line_infos,
                idx,
                line.indent,
                expected_set_indent,
                indent_unit,
                tab_space_size,
                indent_style,
            );

            let assignment_indent = expected_set_indent + indent_unit;
            if line.words.len() > 1 {
                push_keyword_break_edit(
                    &mut edits,
                    statement_sql,
                    &line_infos,
                    &scans,
                    idx,
                    "SET",
                    assignment_indent,
                    indent_unit,
                    tab_space_size,
                    indent_style,
                );
            }

            if let Some(next_idx) = next_significant_line(&scans, idx) {
                if starts_with_assignment(scans[next_idx].trimmed)
                    || scans[idx].trimmed.trim_end().ends_with(',')
                {
                    push_assignment_block_indent_edits(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        &scans,
                        next_idx,
                        assignment_indent,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                }
            }
        }

        if matches!(first, Some("SELECT")) && line.words.len() > 1 && line.trimmed.contains(',') {
            if let Some(prev_idx) = previous_significant_line(&scans, idx) {
                let prev_first = scans[prev_idx].words.first().map(String::as_str);
                if matches!(prev_first, Some("UNION" | "INTERSECT" | "EXCEPT")) {
                    push_keyword_break_edit(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        &scans,
                        idx,
                        "SELECT",
                        line.indent + indent_unit,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                }
            }
        }

        if matches!(first, Some("SELECT"))
            && upper.starts_with("SELECT *")
            && !upper.contains(" FROM ")
        {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_first = scans[next_idx].words.first().map(String::as_str);
                if !is_clause_boundary(next_first, scans[next_idx].trimmed) {
                    push_keyword_break_edit(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        &scans,
                        idx,
                        "SELECT",
                        line.indent + indent_unit,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                }
            }
        }

        if line.trimmed.contains(',') {
            if let Some(partition_idx) = upper.find("PARTITION BY") {
                let partition_tail = &upper[partition_idx..];
                if !partition_tail.contains(')') {
                    if let Some(next_idx) = next_significant_line(&scans, idx) {
                        let next_first = scans[next_idx].words.first().map(String::as_str);
                        if !is_clause_boundary(next_first, scans[next_idx].trimmed) {
                            let break_rel = partition_idx + "PARTITION BY".len();
                            let mut content_rel = break_rel;
                            while let Some(ch) = line.trimmed[content_rel..].chars().next() {
                                if ch.is_whitespace() {
                                    content_rel += ch.len_utf8();
                                } else {
                                    break;
                                }
                            }
                            if content_rel < line.trimmed.len() {
                                push_trimmed_offset_break_edit(
                                    &mut edits,
                                    statement_sql,
                                    &line_infos,
                                    idx,
                                    break_rel,
                                    content_rel,
                                    line.indent + indent_unit,
                                    indent_unit,
                                    tab_space_size,
                                    indent_style,
                                );
                            }
                        }
                    }
                }
            }
        }

        if matches!(first, Some("THEN")) {
            if let Some(expected_indent) =
                expected_then_indent(&scans, &case_anchor_cache, idx, indent_unit)
            {
                push_leading_indent_edit(
                    &mut edits,
                    statement_sql,
                    &line_infos,
                    idx,
                    line.indent,
                    expected_indent,
                    indent_unit,
                    tab_space_size,
                    indent_style,
                );
            }
        }

        if matches!(first, Some("ELSE")) {
            if let Some(expected_indent) =
                expected_else_indent(&scans, &case_anchor_cache, idx, indent_unit)
            {
                push_leading_indent_edit(
                    &mut edits,
                    statement_sql,
                    &line_infos,
                    idx,
                    line.indent,
                    expected_indent,
                    indent_unit,
                    tab_space_size,
                    indent_style,
                );
            }
        }

        if matches!(first, Some("END")) {
            if let Some(expected_indent) =
                expected_end_indent(&scans, &case_anchor_cache, idx, indent_unit)
            {
                push_leading_indent_edit(
                    &mut edits,
                    statement_sql,
                    &line_infos,
                    idx,
                    line.indent,
                    expected_indent,
                    indent_unit,
                    tab_space_size,
                    indent_style,
                );
            }
        }

        if let Some(as_rel) = trailing_as_offset(line.trimmed) {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_line = &scans[next_idx];
                if is_simple_alias_identifier(next_line.trimmed) {
                    if let Some(after_next_idx) = next_significant_line(&scans, next_idx) {
                        let after_next_first =
                            scans[after_next_idx].words.first().map(String::as_str);
                        if matches!(after_next_first, Some("FROM")) {
                            push_trailing_as_alias_break_edit(
                                &mut edits,
                                statement_sql,
                                &line_infos,
                                idx,
                                next_idx,
                                as_rel,
                                line.indent + indent_unit,
                                indent_unit,
                                tab_space_size,
                                indent_style,
                            );
                        }
                    }
                }
            }
        }

        if let Some((arg_open_rel, arg_rel)) = make_interval_inline_arg_offsets(line.trimmed) {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_line = &scans[next_idx];
                if next_line.trimmed.starts_with("=>") {
                    push_make_interval_arg_break_edit(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        idx,
                        arg_open_rel,
                        arg_rel,
                        line.indent + indent_unit,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );

                    push_leading_indent_edit(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        next_idx,
                        next_line.indent,
                        line.indent + indent_unit * 2,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );

                    if let Some(close_rel) = inline_close_paren_offset(next_line.trimmed) {
                        push_close_paren_break_edit(
                            &mut edits,
                            statement_sql,
                            &line_infos,
                            next_idx,
                            close_rel,
                            line.indent + indent_unit,
                            indent_unit,
                            tab_space_size,
                            indent_style,
                        );
                    }
                }
            }
        }

        if is_join_clause(first, second) {
            if should_break_inline_join_on(&scans, idx, first, second, &upper) {
                let Some(on_offset) = inline_join_on_offset(line.trimmed) else {
                    continue;
                };
                push_inline_join_on_break_edit(
                    &mut edits,
                    statement_sql,
                    &line_infos,
                    idx,
                    on_offset,
                    line.indent + indent_unit,
                    indent_unit,
                    tab_space_size,
                    indent_style,
                );
                if let Some(next_idx) = next_significant_line(&scans, idx) {
                    push_on_condition_block_indent_edits(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        &scans,
                        next_idx,
                        line.indent + indent_unit * 2,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                }
            }
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_first = scans[next_idx].words.first().map(String::as_str);
                let next_second = scans[next_idx].words.get(1).map(String::as_str);
                if matches!(next_first, Some("ON")) && !matches!(next_second, Some("CONFLICT")) {
                    push_leading_indent_edit(
                        &mut edits,
                        statement_sql,
                        &line_infos,
                        next_idx,
                        scans[next_idx].indent,
                        line.indent + indent_unit,
                        indent_unit,
                        tab_space_size,
                        indent_style,
                    );
                }
            }
        }
    }

    edits
}

#[allow(clippy::too_many_arguments)]
fn push_keyword_break_edit(
    edits: &mut Vec<Lt02AutofixEdit>,
    statement_sql: &str,
    line_infos: &[StatementLineInfo],
    scans: &[ScanLine<'_>],
    line_index: usize,
    keyword: &str,
    expected_indent: usize,
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) {
    let Some(line) = scans.get(line_index) else {
        return;
    };
    let Some(line_info) = line_infos.get(line_index) else {
        return;
    };
    let Some(content_rel) = content_offset_after_keyword(line.trimmed, keyword) else {
        return;
    };

    let keyword_len = keyword.len();
    if content_rel <= keyword_len {
        return;
    }

    let start = line_info
        .start
        .saturating_add(line_info.indent_end)
        .saturating_add(keyword_len);
    let end = line_info
        .start
        .saturating_add(line_info.indent_end)
        .saturating_add(content_rel);
    if start >= end || end > statement_sql.len() {
        return;
    }

    let replacement = format!(
        "\n{}",
        make_indent(expected_indent, indent_unit, tab_space_size, indent_style)
    );
    if statement_sql[start..end] != replacement {
        edits.push(Lt02AutofixEdit {
            start,
            end,
            replacement,
        });
    }
}

#[allow(clippy::too_many_arguments)]
fn push_trimmed_offset_break_edit(
    edits: &mut Vec<Lt02AutofixEdit>,
    statement_sql: &str,
    line_infos: &[StatementLineInfo],
    line_index: usize,
    break_rel: usize,
    content_rel: usize,
    expected_indent: usize,
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) {
    if content_rel <= break_rel {
        return;
    }
    let Some(line_info) = line_infos.get(line_index) else {
        return;
    };

    let start = line_info
        .start
        .saturating_add(line_info.indent_end)
        .saturating_add(break_rel);
    let end = line_info
        .start
        .saturating_add(line_info.indent_end)
        .saturating_add(content_rel);
    if start >= end || end > statement_sql.len() {
        return;
    }

    let replacement = format!(
        "\n{}",
        make_indent(expected_indent, indent_unit, tab_space_size, indent_style)
    );
    if statement_sql[start..end] != replacement {
        edits.push(Lt02AutofixEdit {
            start,
            end,
            replacement,
        });
    }
}

#[allow(clippy::too_many_arguments)]
fn push_case_when_multiline_break_edit(
    edits: &mut Vec<Lt02AutofixEdit>,
    statement_sql: &str,
    line_infos: &[StatementLineInfo],
    line_index: usize,
    case_break_rel: usize,
    content_rel: usize,
    when_indent: usize,
    content_indent: usize,
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) {
    if content_rel <= case_break_rel {
        return;
    }
    let Some(line_info) = line_infos.get(line_index) else {
        return;
    };

    let start = line_info
        .start
        .saturating_add(line_info.indent_end)
        .saturating_add(case_break_rel);
    let end = line_info
        .start
        .saturating_add(line_info.indent_end)
        .saturating_add(content_rel);
    if start >= end || end > statement_sql.len() {
        return;
    }

    let replacement = format!(
        "\n{}WHEN\n{}",
        make_indent(when_indent, indent_unit, tab_space_size, indent_style),
        make_indent(content_indent, indent_unit, tab_space_size, indent_style)
    );
    if statement_sql[start..end] != replacement {
        edits.push(Lt02AutofixEdit {
            start,
            end,
            replacement,
        });
    }
}

#[allow(clippy::too_many_arguments)]
fn push_inline_case_break_edit(
    edits: &mut Vec<Lt02AutofixEdit>,
    statement_sql: &str,
    line_infos: &[StatementLineInfo],
    line_index: usize,
    case_rel: usize,
    expected_indent: usize,
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) {
    let Some(line_info) = line_infos.get(line_index) else {
        return;
    };

    let start = line_info
        .start
        .saturating_add(line_info.indent_end)
        .saturating_add(case_rel.saturating_sub(1));
    let end = start.saturating_add(1);
    if start >= end || end > statement_sql.len() {
        return;
    }

    let replacement = format!(
        "\n{}",
        make_indent(expected_indent, indent_unit, tab_space_size, indent_style)
    );
    if statement_sql[start..end] != replacement {
        edits.push(Lt02AutofixEdit {
            start,
            end,
            replacement,
        });
    }
}

#[allow(clippy::too_many_arguments)]
fn push_make_interval_arg_break_edit(
    edits: &mut Vec<Lt02AutofixEdit>,
    statement_sql: &str,
    line_infos: &[StatementLineInfo],
    line_index: usize,
    arg_open_rel: usize,
    arg_rel: usize,
    expected_indent: usize,
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) {
    let Some(line_info) = line_infos.get(line_index) else {
        return;
    };
    let line_start = line_info.start + line_info.indent_end;
    let start = line_start + arg_open_rel;
    let end = line_start + arg_rel;
    if start > end || end > statement_sql.len() {
        return;
    }

    let replacement = format!(
        "\n{}",
        make_indent(expected_indent, indent_unit, tab_space_size, indent_style)
    );
    if statement_sql[start..end] != replacement {
        edits.push(Lt02AutofixEdit {
            start,
            end,
            replacement,
        });
    }
}

#[allow(clippy::too_many_arguments)]
fn push_trailing_as_alias_break_edit(
    edits: &mut Vec<Lt02AutofixEdit>,
    statement_sql: &str,
    line_infos: &[StatementLineInfo],
    line_index: usize,
    next_line_index: usize,
    as_rel: usize,
    expected_indent: usize,
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) {
    let Some(line_info) = line_infos.get(line_index) else {
        return;
    };
    let Some(next_info) = line_infos.get(next_line_index) else {
        return;
    };
    let start = line_info
        .start
        .saturating_add(line_info.indent_end)
        .saturating_add(as_rel);
    let end = next_info.start.saturating_add(next_info.indent_end);
    if start >= end || end > statement_sql.len() {
        return;
    }

    let indent = make_indent(expected_indent, indent_unit, tab_space_size, indent_style);
    let replacement = format!("\n{indent}AS\n{indent}");
    if statement_sql[start..end] != replacement {
        edits.push(Lt02AutofixEdit {
            start,
            end,
            replacement,
        });
    }
}

#[allow(clippy::too_many_arguments)]
fn push_close_paren_break_edit(
    edits: &mut Vec<Lt02AutofixEdit>,
    statement_sql: &str,
    line_infos: &[StatementLineInfo],
    line_index: usize,
    close_rel: usize,
    expected_indent: usize,
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) {
    let Some(line_info) = line_infos.get(line_index) else {
        return;
    };
    let line_start = line_info.start + line_info.indent_end;
    let start = line_start + close_rel;
    if start > statement_sql.len() {
        return;
    }

    let replacement = format!(
        "\n{}",
        make_indent(expected_indent, indent_unit, tab_space_size, indent_style)
    );
    edits.push(Lt02AutofixEdit {
        start,
        end: start,
        replacement,
    });
}

#[allow(clippy::too_many_arguments)]
fn push_inline_join_on_break_edit(
    edits: &mut Vec<Lt02AutofixEdit>,
    statement_sql: &str,
    line_infos: &[StatementLineInfo],
    line_index: usize,
    on_keyword_rel: usize,
    expected_indent: usize,
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) {
    let Some(line_info) = line_infos.get(line_index) else {
        return;
    };
    let line_start = line_info.start + line_info.indent_end;
    let line_end = statement_sql[line_start..]
        .find('\n')
        .map(|relative| line_start + relative)
        .unwrap_or(statement_sql.len());
    if line_end <= line_start || line_end > statement_sql.len() {
        return;
    }
    if on_keyword_rel == 0 {
        return;
    }

    let start = line_start + on_keyword_rel - 1;
    let end = start + 1;
    if end > statement_sql.len() || start >= end {
        return;
    }
    let replacement = format!(
        "\n{}",
        make_indent(expected_indent, indent_unit, tab_space_size, indent_style)
    );
    if statement_sql[start..end] != replacement {
        edits.push(Lt02AutofixEdit {
            start,
            end,
            replacement,
        });
    }
}

#[allow(clippy::too_many_arguments)]
fn push_leading_indent_edit(
    edits: &mut Vec<Lt02AutofixEdit>,
    statement_sql: &str,
    line_infos: &[StatementLineInfo],
    line_index: usize,
    current_indent: usize,
    expected_indent: usize,
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) {
    if current_indent == expected_indent {
        return;
    }
    let Some(line_info) = line_infos.get(line_index) else {
        return;
    };
    let start = line_info.start;
    let end = line_info.start + line_info.indent_end;
    if end > statement_sql.len() || start > end {
        return;
    }

    let replacement = make_indent(expected_indent, indent_unit, tab_space_size, indent_style);
    if statement_sql[start..end] != replacement {
        edits.push(Lt02AutofixEdit {
            start,
            end,
            replacement,
        });
    }
}

#[allow(clippy::too_many_arguments)]
fn push_on_condition_block_indent_edits(
    edits: &mut Vec<Lt02AutofixEdit>,
    statement_sql: &str,
    line_infos: &[StatementLineInfo],
    scans: &[ScanLine<'_>],
    start_idx: usize,
    base_indent: usize,
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) {
    let mut depth = 0isize;
    let mut suspended_nested_clause_depth: Option<isize> = None;
    let mut idx = start_idx;
    while idx < scans.len() {
        let line = &scans[idx];
        if line.is_blank || contains_template_marker(line.trimmed) {
            idx += 1;
            continue;
        }

        let first = line.words.first().map(String::as_str);

        if let Some(suspended_depth) = suspended_nested_clause_depth {
            let at_resume_boundary =
                depth == suspended_depth && is_clause_boundary(first, line.trimmed);
            if !at_resume_boundary {
                if !line.is_comment_only {
                    depth += paren_delta_simple(line.trimmed);
                    if depth < 0 {
                        depth = 0;
                    }
                }
                idx += 1;
                continue;
            }
            suspended_nested_clause_depth = None;
        }

        if depth > 0 && matches!(first, Some("WHERE" | "HAVING")) && line.words.len() == 1 {
            // Let nested WHERE/HAVING blocks compute their own child indentation.
            suspended_nested_clause_depth = Some(depth);
            if !line.is_comment_only {
                depth += paren_delta_simple(line.trimmed);
                if depth < 0 {
                    depth = 0;
                }
            }
            idx += 1;
            continue;
        }

        let starts_with_close_paren = line.trimmed.starts_with(')');
        let starts_with_open_paren = line.trimmed.starts_with('(');
        let is_continuation_line = matches!(first, Some("AND" | "OR" | "NOT" | "EXISTS"))
            || starts_with_operator_continuation(line.trimmed)
            || (starts_with_close_paren && depth > 0)
            || (starts_with_open_paren && depth > 0)
            || line.is_comment_only;
        if depth <= 0 && !is_continuation_line && is_clause_boundary(first, line.trimmed) {
            break;
        }

        let logical_depth = depth.max(0) as usize;
        let expected_indent = if line.trimmed.starts_with(')') {
            if logical_depth > 0 {
                base_indent + ((logical_depth - 1) * indent_unit)
            } else {
                base_indent
            }
        } else {
            base_indent + (logical_depth * indent_unit)
        };

        push_leading_indent_edit(
            edits,
            statement_sql,
            line_infos,
            idx,
            line.indent,
            expected_indent,
            indent_unit,
            tab_space_size,
            indent_style,
        );

        if !line.is_comment_only {
            depth += paren_delta_simple(line.trimmed);
            if depth < 0 {
                depth = 0;
            }
        }
        idx += 1;
    }
}

#[allow(clippy::too_many_arguments)]
fn push_assignment_block_indent_edits(
    edits: &mut Vec<Lt02AutofixEdit>,
    statement_sql: &str,
    line_infos: &[StatementLineInfo],
    scans: &[ScanLine<'_>],
    start_idx: usize,
    expected_indent: usize,
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) {
    let mut idx = start_idx;
    while idx < scans.len() {
        let line = &scans[idx];
        if line.is_blank || line.is_comment_only || contains_template_marker(line.trimmed) {
            idx += 1;
            continue;
        }

        let first = line.words.first().map(String::as_str);
        if starts_with_assignment(line.trimmed) || matches!(first, Some("AND" | "OR")) {
            push_leading_indent_edit(
                edits,
                statement_sql,
                line_infos,
                idx,
                line.indent,
                expected_indent,
                indent_unit,
                tab_space_size,
                indent_style,
            );
            idx += 1;
            continue;
        }

        if is_clause_boundary(first, line.trimmed) || line.trimmed.starts_with(';') {
            break;
        }

        let previous_ended_comma = previous_significant_line(scans, idx)
            .is_some_and(|prev_idx| scans[prev_idx].trimmed.trim_end().ends_with(','));
        if previous_ended_comma {
            push_leading_indent_edit(
                edits,
                statement_sql,
                line_infos,
                idx,
                line.indent,
                expected_indent,
                indent_unit,
                tab_space_size,
                indent_style,
            );
            idx += 1;
            continue;
        }

        break;
    }
}

fn push_line_start_issue_span(
    issue_spans: &mut Vec<(usize, usize)>,
    line_infos: &[StatementLineInfo],
    line_index: usize,
    sql_len: usize,
) {
    let Some(line_info) = line_infos.get(line_index) else {
        return;
    };
    let start = line_info.start.min(sql_len);
    let end = (start + 1).min(sql_len);
    issue_spans.push((start, end.max(start)));
}

fn push_trimmed_offset_issue_span(
    issue_spans: &mut Vec<(usize, usize)>,
    line_infos: &[StatementLineInfo],
    line_index: usize,
    trimmed_offset: usize,
    sql_len: usize,
) {
    let Some(line_info) = line_infos.get(line_index) else {
        return;
    };

    let start = line_info
        .start
        .saturating_add(line_info.indent_end)
        .saturating_add(trimmed_offset)
        .min(sql_len);
    let end = (start + 1).min(sql_len);
    issue_spans.push((start, end.max(start)));
}

fn starts_with_assignment(trimmed: &str) -> bool {
    let bytes = trimmed.as_bytes();
    if bytes.is_empty() || !(bytes[0].is_ascii_alphabetic() || bytes[0] == b'_') {
        return false;
    }

    let mut index = 1usize;
    while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') {
        index += 1;
    }
    while index < bytes.len() && bytes[index].is_ascii_whitespace() {
        index += 1;
    }

    index < bytes.len() && bytes[index] == b'='
}

fn starts_with_operator_continuation(trimmed: &str) -> bool {
    let trimmed = trimmed.trim_start();
    trimmed.starts_with('=')
        || trimmed.starts_with('+')
        || trimmed.starts_with('*')
        || trimmed.starts_with('/')
        || trimmed.starts_with('%')
        || trimmed.starts_with("||")
        || trimmed.starts_with("->")
        || trimmed.starts_with("->>")
        || (trimmed.starts_with('-')
            && !trimmed
                .chars()
                .nth(1)
                .is_some_and(|ch| ch.is_ascii_alphanumeric()))
}

fn inline_join_on_offset(trimmed: &str) -> Option<usize> {
    let upper = trimmed.to_ascii_uppercase();
    if let Some(space_before_on) = upper.find(" ON ") {
        return Some(space_before_on + 1);
    }
    if upper.ends_with(" ON") || upper.ends_with(" ON (") {
        return upper
            .rfind(" ON")
            .map(|space_before_on| space_before_on + 1);
    }
    None
}

fn inline_case_keyword_offset(trimmed: &str) -> Option<usize> {
    let upper = trimmed.to_ascii_uppercase();
    upper
        .find(" CASE")
        .map(|space_before_case| space_before_case + 1)
}

fn should_break_inline_join_on(
    scans: &[ScanLine<'_>],
    line_index: usize,
    first_word: Option<&str>,
    second_word: Option<&str>,
    upper_trimmed: &str,
) -> bool {
    if upper_trimmed.ends_with(" ON") || upper_trimmed.ends_with(" ON (") {
        return true;
    }

    if !matches!(first_word, Some("JOIN")) {
        return false;
    }
    if !is_join_clause(first_word, second_word) {
        return false;
    }
    if inline_join_on_offset(scans[line_index].trimmed).is_none() {
        return false;
    }

    previous_significant_line(scans, line_index).is_some_and(|prev_idx| {
        let prev_first = scans[prev_idx].words.first().map(String::as_str);
        let prev_second = scans[prev_idx].words.get(1).map(String::as_str);
        matches!(
            prev_first,
            Some("LEFT" | "RIGHT" | "FULL" | "INNER" | "OUTER" | "CROSS" | "NATURAL")
        ) && !matches!(prev_second, Some("JOIN" | "APPLY"))
    })
}

fn content_offset_after_keyword(trimmed: &str, keyword: &str) -> Option<usize> {
    if trimmed.len() < keyword.len()
        || !trimmed
            .get(..keyword.len())
            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(keyword))
    {
        return None;
    }

    let mut index = keyword.len();
    let first_after = trimmed[index..].chars().next()?;
    if !first_after.is_whitespace() {
        return None;
    }

    while let Some(ch) = trimmed[index..].chars().next() {
        if ch.is_whitespace() {
            index += ch.len_utf8();
        } else {
            break;
        }
    }

    (index < trimmed.len()).then_some(index)
}

fn trailing_as_offset(trimmed: &str) -> Option<usize> {
    let upper = trimmed.to_ascii_uppercase();
    let as_rel = upper.rfind(" AS")?;
    (as_rel > 0 && as_rel + " AS".len() == trimmed.len()).then_some(as_rel)
}

fn is_simple_alias_identifier(trimmed: &str) -> bool {
    if trimmed.is_empty() {
        return false;
    }
    let bytes = trimmed.as_bytes();
    if !(bytes[0].is_ascii_alphabetic() || bytes[0] == b'_') {
        return false;
    }
    bytes[1..]
        .iter()
        .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
}

fn make_interval_inline_arg_offsets(trimmed: &str) -> Option<(usize, usize)> {
    let upper = trimmed.to_ascii_uppercase();
    let open_rel = upper.find("MAKE_INTERVAL(")? + "MAKE_INTERVAL(".len();
    if open_rel >= trimmed.len() {
        return None;
    }

    let mut arg_rel = open_rel;
    while let Some(ch) = trimmed[arg_rel..].chars().next() {
        if ch.is_whitespace() {
            arg_rel += ch.len_utf8();
        } else {
            break;
        }
    }

    (arg_rel < trimmed.len()).then_some((open_rel, arg_rel))
}

fn make_interval_inline_arg_offset(trimmed: &str) -> Option<usize> {
    make_interval_inline_arg_offsets(trimmed).map(|(_, arg_rel)| arg_rel)
}

fn inline_close_paren_offset(trimmed: &str) -> Option<usize> {
    if !trimmed.trim_start().starts_with("=>") {
        return None;
    }
    trimmed.rfind(')')
}

fn push_join_on_block_indent_spans(
    issue_spans: &mut Vec<(usize, usize)>,
    line_infos: &[StatementLineInfo],
    scans: &[ScanLine<'_>],
    join_line_idx: usize,
    indent_unit: usize,
    sql_len: usize,
) {
    let Some(join_line) = scans.get(join_line_idx) else {
        return;
    };
    let join_indent = rounded_indent_width(join_line.indent, indent_unit);
    let base_indent = join_indent + (indent_unit * 2);
    let on_has_open_paren = join_line.trimmed.to_ascii_uppercase().ends_with(" ON (");
    let mut depth: isize = if on_has_open_paren { 1 } else { 0 };

    let mut idx = join_line_idx + 1;
    while idx < scans.len() {
        let line = &scans[idx];
        if line.is_blank || line.is_comment_only || contains_template_marker(line.trimmed) {
            idx += 1;
            continue;
        }

        let first = line.words.first().map(String::as_str);
        let starts_with_close_paren = line.trimmed.starts_with(')');
        let starts_with_open_paren = line.trimmed.starts_with('(');
        let is_continuation_line = matches!(first, Some("AND" | "OR" | "NOT" | "EXISTS"))
            || starts_with_operator_continuation(line.trimmed)
            || (starts_with_close_paren && depth > 0)
            || (starts_with_open_paren && depth > 0);
        if depth <= 0 && !is_continuation_line && is_clause_boundary(first, line.trimmed) {
            break;
        }

        let logical_depth = if on_has_open_paren {
            depth.saturating_sub(1) as usize
        } else {
            depth.max(0) as usize
        };
        let expected_indent = if line.trimmed.starts_with(')') {
            if logical_depth > 0 {
                base_indent + ((logical_depth - 1) * indent_unit)
            } else {
                join_indent + indent_unit
            }
        } else {
            base_indent + (logical_depth * indent_unit)
        };

        if line.indent != expected_indent {
            push_line_start_issue_span(issue_spans, line_infos, idx, sql_len);
        }

        depth += paren_delta_simple(line.trimmed);
        if depth < 0 {
            depth = 0;
        }
        idx += 1;
    }
}

fn find_andor_anchor(scans: &[ScanLine<'_>], from_idx: usize) -> Option<usize> {
    (0..from_idx)
        .rev()
        .find_map(|idx| {
            let line = &scans[idx];
            if line.is_blank || line.is_comment_only {
                return None;
            }
            let first = line.words.first().map(String::as_str);
            if matches!(first, Some("WHERE" | "ON" | "HAVING" | "WHEN")) {
                return Some(idx);
            }
            if is_clause_boundary(first, line.trimmed) && !matches!(first, Some("AND" | "OR")) {
                return Some(usize::MAX);
            }
            None
        })
        .and_then(|idx| (idx != usize::MAX).then_some(idx))
}

fn find_case_when_anchor(scans: &[ScanLine<'_>], from_idx: usize) -> Option<usize> {
    (0..from_idx)
        .rev()
        .find_map(|idx| {
            let line = &scans[idx];
            if line.is_blank || line.is_comment_only {
                return None;
            }
            let first = line.words.first().map(String::as_str);
            if matches!(first, Some("WHEN")) {
                return Some(idx);
            }
            if is_clause_boundary(first, line.trimmed)
                && !matches!(first, Some("AND" | "OR" | "THEN" | "ELSE"))
            {
                return Some(usize::MAX);
            }
            None
        })
        .and_then(|idx| (idx != usize::MAX).then_some(idx))
}

fn build_case_anchor_cache(scans: &[ScanLine<'_>]) -> Vec<Option<usize>> {
    let mut cache = vec![None; scans.len()];
    let mut current_anchor: Option<usize> = None;

    for (idx, line) in scans.iter().enumerate() {
        // Record the anchor *before* inspecting the current line so that a
        // CASE line references its enclosing anchor, while lines that follow
        // (WHEN, THEN, ELSE, END) reference back to the CASE itself.
        cache[idx] = current_anchor;
        if line.is_blank || line.is_comment_only {
            continue;
        }

        let first = line.words.first().map(String::as_str);
        if matches!(first, Some("CASE")) || line.inline_case_offset.is_some() {
            current_anchor = Some(idx);
            continue;
        }

        if is_clause_boundary(first, line.trimmed)
            && !matches!(first, Some("WHEN" | "THEN" | "ELSE" | "END" | "AND" | "OR"))
        {
            current_anchor = None;
        }
    }

    cache
}

fn find_case_anchor(case_anchor_cache: &[Option<usize>], from_idx: usize) -> Option<usize> {
    case_anchor_cache.get(from_idx).copied().flatten()
}

fn case_line_indent_for_anchor(
    scans: &[ScanLine<'_>],
    anchor_idx: usize,
    indent_unit: usize,
) -> usize {
    let anchor = &scans[anchor_idx];
    let first = anchor.words.first().map(String::as_str);
    if matches!(first, Some("CASE")) {
        anchor.indent
    } else if anchor.inline_case_offset.is_some() {
        anchor.indent + indent_unit
    } else {
        anchor.indent
    }
}

fn expected_case_when_indent(
    scans: &[ScanLine<'_>],
    case_anchor_cache: &[Option<usize>],
    line_index: usize,
    indent_unit: usize,
) -> Option<usize> {
    let case_anchor = find_case_anchor(case_anchor_cache, line_index)?;
    let case_indent = case_line_indent_for_anchor(scans, case_anchor, indent_unit);
    Some(case_indent + indent_unit)
}

fn expected_then_indent(
    scans: &[ScanLine<'_>],
    case_anchor_cache: &[Option<usize>],
    line_index: usize,
    indent_unit: usize,
) -> Option<usize> {
    let prev_idx = previous_significant_line(scans, line_index)?;
    let prev = scans.get(prev_idx)?;
    let prev_first = prev.words.first().map(String::as_str);

    if matches!(prev_first, Some("AND" | "OR")) {
        return Some(prev.indent + indent_unit);
    }
    if prev.trimmed.starts_with("=>") {
        return Some(prev.indent);
    }

    if let Some(case_anchor) = find_case_anchor(case_anchor_cache, line_index) {
        let case_indent = case_line_indent_for_anchor(scans, case_anchor, indent_unit);
        return Some(case_indent + indent_unit * 2);
    }

    find_case_when_anchor(scans, line_index)
        .map(|when_idx| scans[when_idx].indent + indent_unit * 2)
}

fn expected_else_indent(
    scans: &[ScanLine<'_>],
    case_anchor_cache: &[Option<usize>],
    line_index: usize,
    indent_unit: usize,
) -> Option<usize> {
    let case_anchor = find_case_anchor(case_anchor_cache, line_index)?;
    let case_indent = case_line_indent_for_anchor(scans, case_anchor, indent_unit);
    Some(case_indent + indent_unit)
}

fn expected_end_indent(
    scans: &[ScanLine<'_>],
    case_anchor_cache: &[Option<usize>],
    line_index: usize,
    indent_unit: usize,
) -> Option<usize> {
    let case_anchor = find_case_anchor(case_anchor_cache, line_index)?;
    Some(case_line_indent_for_anchor(scans, case_anchor, indent_unit))
}

fn paren_depth_between(scans: &[ScanLine<'_>], start_idx: usize, end_idx: usize) -> usize {
    if start_idx >= end_idx || end_idx > scans.len() {
        return 0;
    }

    let depth = scans[start_idx..end_idx]
        .iter()
        .fold(0isize, |acc, line| acc + paren_delta_simple(line.trimmed));

    depth.max(0) as usize
}

fn paren_delta_simple(text: &str) -> isize {
    text.chars().fold(0isize, |acc, ch| match ch {
        '(' => acc + 1,
        ')' => acc - 1,
        _ => acc,
    })
}

// ---------------------------------------------------------------------------
// Generic (dialect-agnostic) indentation detection and helpers
// ---------------------------------------------------------------------------

fn first_line_is_indented(ctx: &LintContext) -> bool {
    let statement_start = ctx.statement_range.start;
    if statement_start == 0 {
        return false;
    }

    let line_start = ctx.sql[..statement_start]
        .rfind('\n')
        .map_or(0, |index| index + 1);
    let leading = &ctx.sql[line_start..statement_start];
    !leading.is_empty() && leading.chars().all(char::is_whitespace)
}

fn ignore_first_line_indent_for_fragmented_statement(ctx: &LintContext) -> bool {
    if ctx.statement_index == 0 || ctx.statement_range.start == 0 {
        return false;
    }

    let prefix = &ctx.sql[..ctx.statement_range.start.min(ctx.sql.len())];
    let prev_non_ws = prefix.chars().rev().find(|ch| !ch.is_whitespace());
    matches!(prev_non_ws, Some(ch) if ch != ';')
}

fn first_line_is_template_fragment(ctx: &LintContext) -> bool {
    let statement_start = ctx.statement_range.start;
    if statement_start == 0 {
        return false;
    }

    let line_start = ctx.sql[..statement_start]
        .rfind('\n')
        .map_or(0, |index| index + 1);
    let leading = &ctx.sql[line_start..statement_start];
    if leading.is_empty() || !leading.chars().all(char::is_whitespace) {
        return false;
    }

    let before_line = &ctx.sql[..line_start];
    for raw_line in before_line.lines().rev() {
        let trimmed = raw_line.trim();
        if trimmed.is_empty() {
            continue;
        }
        return is_template_boundary_line(trimmed);
    }

    false
}

// ---------------------------------------------------------------------------
// Structural indent detection
// ---------------------------------------------------------------------------

/// Returns true if any line has indentation that violates structural
/// expectations. This catches cases where all indents are valid multiples
/// of indent_unit but are at the wrong depth for their SQL context.
///
/// The check focuses on "standalone clause keyword" patterns: when a clause
/// keyword that expects indented content (SELECT, FROM, WHERE, SET, etc.)
/// appears alone on a line, the content on the following line must be
/// indented by one indent_unit.
/// Returns autofix edits for structural indentation violations. When empty,
/// no structural violation was found.
fn structural_indent_edits(
    ctx: &LintContext,
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
    options: &LayoutIndent,
) -> Vec<Lt02AutofixEdit> {
    let sql = ctx.statement_sql();

    if options.ignore_templated_areas && contains_template_marker(sql) {
        return Vec::new();
    }

    // Skip structural check for templated SQL. Template expansion can
    // produce indentation patterns that look structurally wrong but are
    // correct in the original source.
    if ctx.is_templated() {
        return Vec::new();
    }

    // Try to tokenize; if we cannot, fall back to no structural check.
    let tokens = tokenize_for_structural_check(sql, ctx);
    let tokens = match tokens.as_deref() {
        Some(t) if !t.is_empty() => t,
        _ => return Vec::new(),
    };

    // Build per-line token info.
    let line_infos = build_line_token_infos(tokens);
    if line_infos.is_empty() {
        return Vec::new();
    }

    let actual_indents = actual_indent_map(sql, tab_space_size);
    let line_info_list = statement_line_infos(sql);
    let mut edits = Vec::new();

    // Check for structural violations: when a content-bearing clause keyword
    // is alone on its line, the following content line must be indented by
    // indent_unit more than the keyword line.
    let lines: Vec<usize> = line_infos.keys().copied().collect();
    for (i, &line) in lines.iter().enumerate() {
        let info = &line_infos[&line];
        if !info.is_standalone_content_clause {
            continue;
        }

        let keyword_indent = actual_indents.get(&line).copied().unwrap_or(0);
        let expected_content_indent = keyword_indent + indent_unit;

        // Find the next content line (skip blank lines).
        if let Some(&next_line) = lines.get(i + 1) {
            let next_info = &line_infos[&next_line];
            // Skip content lines that are clause keywords (they set their
            // own indent context) or SELECT modifiers (DISTINCT/ALL) which
            // belong with the preceding SELECT, not as indented content.
            if !next_info.starts_with_clause_keyword && !next_info.starts_with_select_modifier {
                let next_actual = actual_indents.get(&next_line).copied().unwrap_or(0);
                if next_actual != expected_content_indent {
                    if let Some(line_info) = line_info_list.get(next_line) {
                        let start = line_info.start;
                        let end = line_info.start + line_info.indent_end;
                        if end <= sql.len() && start <= end {
                            let replacement = make_indent(
                                expected_content_indent,
                                indent_unit,
                                tab_space_size,
                                indent_style,
                            );
                            edits.push(Lt02AutofixEdit {
                                start,
                                end,
                                replacement,
                            });
                        }
                    }
                }
            }
        }
    }

    // Check for comment-only trailing lines at deeper indentation than
    // any content line. E.g. `SELECT 1\n    -- foo\n        -- bar`
    // has comments indented beyond the content (indent 0), which is wrong.
    if let Some(&last_content_line) = line_infos
        .iter()
        .rev()
        .find(|(_, info)| !info.is_comment_only)
        .map(|(line, _)| line)
    {
        let last_content_indent = actual_indents.get(&last_content_line).copied().unwrap_or(0);
        // Check comment-only lines after the last content line.
        for (&line, info) in &line_infos {
            if line > last_content_line && info.is_comment_only {
                if options.ignore_comment_lines {
                    continue;
                }
                let comment_indent = actual_indents.get(&line).copied().unwrap_or(0);
                if comment_indent > last_content_indent {
                    if let Some(line_info) = line_info_list.get(line) {
                        let start = line_info.start;
                        let end = line_info.start + line_info.indent_end;
                        if end <= sql.len() && start <= end {
                            let replacement = make_indent(
                                last_content_indent,
                                indent_unit,
                                tab_space_size,
                                indent_style,
                            );
                            edits.push(Lt02AutofixEdit {
                                start,
                                end,
                                replacement,
                            });
                        }
                    }
                }
            }
        }
    }

    edits
}

struct ScanLine<'a> {
    trimmed: &'a str,
    indent: usize,
    words: Vec<String>,
    is_blank: bool,
    is_comment_only: bool,
    inline_case_offset: Option<usize>,
    prev_significant: Option<usize>,
    next_significant: Option<usize>,
}

fn link_significant_lines(scans: &mut [ScanLine<'_>]) {
    let mut prev_significant = None;
    for (idx, scan) in scans.iter_mut().enumerate() {
        scan.prev_significant = prev_significant;
        if !scan.is_blank && !scan.is_comment_only {
            prev_significant = Some(idx);
        }
    }

    let mut next_significant = None;
    for idx in (0..scans.len()).rev() {
        scans[idx].next_significant = next_significant;
        if !scans[idx].is_blank && !scans[idx].is_comment_only {
            next_significant = Some(idx);
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TemplateMultilineMode {
    Expression,
    Statement,
    Comment,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TemplateControlKind {
    Open,
    Mid,
    Close,
}

#[derive(Clone, Debug)]
struct TemplateControlTag {
    kind: TemplateControlKind,
    keyword: Option<String>,
}

fn detect_additional_indentation_violation(
    sql: &str,
    indent_unit: usize,
    tab_space_size: usize,
    options: &LayoutIndent,
    dialect: Dialect,
) -> bool {
    let lines: Vec<&str> = sql.lines().collect();
    if lines.is_empty() {
        return false;
    }

    let indent_map = actual_indent_map(sql, tab_space_size);
    let mut scans: Vec<_> = lines
        .iter()
        .enumerate()
        .map(|(line_idx, line)| {
            let trimmed = line.trim_start();
            let is_blank = trimmed.trim().is_empty();
            let is_comment_only = is_comment_line(trimmed);
            let words = if is_blank {
                Vec::new()
            } else {
                split_upper_words(trimmed)
            };
            ScanLine {
                trimmed,
                indent: indent_map.get(&line_idx).copied().unwrap_or(0),
                words,
                is_blank,
                is_comment_only,
                inline_case_offset: None,
                prev_significant: None,
                next_significant: None,
            }
        })
        .collect();
    link_significant_lines(&mut scans);

    let template_only_lines = template_only_line_flags(&lines);
    let mut sql_template_block_indents: Vec<usize> = Vec::new();

    for idx in 0..scans.len() {
        let line = &scans[idx];
        if line.is_blank {
            continue;
        }

        let template_controls = template_control_tags_in_line(line.trimmed);
        if !template_controls.is_empty() {
            for tag in template_controls {
                match tag.kind {
                    TemplateControlKind::Open => {
                        if tag
                            .keyword
                            .as_deref()
                            .is_none_or(|keyword| !is_non_sql_template_keyword(keyword))
                        {
                            sql_template_block_indents.push(line.indent);
                        }
                    }
                    TemplateControlKind::Mid => {
                        if let Some(expected_indent) = sql_template_block_indents.last() {
                            if line.indent != *expected_indent {
                                return true;
                            }
                        }
                    }
                    TemplateControlKind::Close => {
                        if tag
                            .keyword
                            .as_deref()
                            .is_none_or(|keyword| !is_non_sql_template_keyword(keyword))
                        {
                            if let Some(open_indent) = sql_template_block_indents.pop() {
                                if line.indent != open_indent {
                                    return true;
                                }
                            }
                        }
                    }
                }
            }
            continue;
        }

        if template_only_lines.get(idx).copied().unwrap_or(false) {
            continue;
        }

        let in_sql_template_block = !sql_template_block_indents.is_empty();
        if in_sql_template_block {
            let required_indent = sql_template_block_indents[0] + indent_unit;
            if line.indent < required_indent {
                return true;
            }
        }

        let first = line.words.first().map(String::as_str);
        let second = line.words.get(1).map(String::as_str);

        if matches!(first, Some("ELSE")) && words_contain_in_order(&line.words, "ELSE", "END") {
            return true;
        }

        let upper = line.trimmed.to_ascii_uppercase();
        if upper.contains(" AS (SELECT") || upper.starts_with("(SELECT") {
            return true;
        }

        if matches!(first, Some("DECLARE")) && line.words.len() > 1 {
            return true;
        }

        if upper.contains(" PROCEDURE") {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                if scans[next_idx].trimmed.starts_with('@') && scans[next_idx].indent <= line.indent
                {
                    return true;
                }
            }
        }

        if matches!(first, Some("ELSE")) {
            if idx == 0 && matches!(dialect, Dialect::Mssql) {
                return true;
            }
            if let Some(prev_idx) =
                previous_line_matching(&scans, idx, |f, _| matches!(f, Some("IF" | "ELSE")))
            {
                if line.indent > scans[prev_idx].indent {
                    return true;
                }
            }
        }

        if options.indented_ctes && matches!(first, Some("WITH")) {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                if scans[next_idx].indent != line.indent + indent_unit {
                    return true;
                }
            }
        }

        if is_content_clause_line(first, line.trimmed) {
            let expected_indent = line.indent + indent_unit;
            let mut scan_idx = idx + 1;
            while scan_idx < scans.len() {
                let next = &scans[scan_idx];
                if next.is_blank {
                    scan_idx += 1;
                    continue;
                }
                if template_only_lines.get(scan_idx).copied().unwrap_or(false) {
                    let one_line_template_expr =
                        next.trimmed.starts_with("{{") && next.trimmed.contains("}}");
                    if one_line_template_expr {
                        // One-line templated expressions can still represent
                        // content elements (e.g. SELECT list items) and should
                        // keep their surrounding indentation.
                    } else {
                        scan_idx += 1;
                        continue;
                    }
                }
                let next_first = next.words.first().map(String::as_str);
                if is_clause_boundary(next_first, next.trimmed) {
                    break;
                }
                if next.is_comment_only {
                    scan_idx += 1;
                    continue;
                }
                if next.indent < expected_indent {
                    return true;
                }
                scan_idx += 1;
            }
        }

        if is_join_clause(first, second) && !in_sql_template_block {
            if let Some(prev_join_idx) = previous_line_matching(&scans, idx, is_join_clause) {
                let prev_first = scans[prev_join_idx].words.first().map(String::as_str);
                let prev_second = scans[prev_join_idx].words.get(1).map(String::as_str);
                if previous_significant_line(&scans, idx) == Some(prev_join_idx)
                    && join_requires_condition(prev_first, prev_second)
                    && line.indent < scans[prev_join_idx].indent + indent_unit
                {
                    return true;
                }
            }

            let parent_from_indent =
                previous_line_indent_matching(&scans, idx, |f, _| matches!(f, Some("FROM")))
                    .or_else(|| previous_line_indent_matching(&scans, idx, is_join_clause))
                    .unwrap_or(0);
            let expected = parent_from_indent
                + if options.indented_joins {
                    indent_unit
                } else {
                    0
                };
            if line.indent != expected {
                return true;
            }
        }

        if matches!(first, Some("USING" | "ON")) {
            let parent_indent = previous_line_indent_matching(&scans, idx, |f, s| {
                is_join_clause(f, s) || matches!(f, Some("USING"))
            })
            .unwrap_or(0);
            let expected = parent_indent
                + if options.indented_using_on {
                    indent_unit
                } else {
                    0
                };
            if line.indent != expected {
                return true;
            }
        }

        if line.is_comment_only && !options.ignore_comment_lines {
            if let (Some(prev_idx), Some(next_idx)) = (
                previous_significant_line(&scans, idx),
                next_significant_line(&scans, idx),
            ) {
                if is_join_clause(
                    scans[next_idx].words.first().map(String::as_str),
                    scans[next_idx].words.get(1).map(String::as_str),
                ) || matches!(
                    scans[next_idx].words.first().map(String::as_str),
                    Some("FROM" | "WHERE" | "HAVING" | "QUALIFY" | "LIMIT")
                ) {
                    let allowed = scans[prev_idx].indent.max(scans[next_idx].indent);
                    if line.indent > allowed {
                        return true;
                    }
                }
            }
        }

        if options.implicit_indents == ImplicitIndentsMode::Require
            && matches!(first, Some("WHERE" | "HAVING" | "ON" | "CASE"))
            && line.words.len() == 1
        {
            return true;
        }

        if options.implicit_indents == ImplicitIndentsMode::Allow
            && matches!(first, Some("WHERE"))
            && line.words.len() > 1
            && line.trimmed.contains('(')
            && !line.trimmed.contains(')')
            && !line.trimmed.trim_end().ends_with('(')
        {
            return true;
        }

        if matches!(first, Some("WHERE" | "HAVING")) && line.words.len() > 1 {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                if matches!(
                    scans[next_idx].words.first().map(String::as_str),
                    Some("AND" | "OR")
                ) {
                    match options.implicit_indents {
                        ImplicitIndentsMode::Forbid => return true,
                        ImplicitIndentsMode::Allow | ImplicitIndentsMode::Require => {
                            if scans[next_idx].indent < line.indent + indent_unit {
                                return true;
                            }
                        }
                    }
                }
            }
        }

        if matches!(first, Some("ON")) {
            if options.indented_on_contents {
                let on_has_inline = line.words.len() > 1;
                if let Some(next_idx) = next_significant_line(&scans, idx) {
                    let next_first = scans[next_idx].words.first().map(String::as_str);
                    if on_has_inline && matches!(next_first, Some("AND" | "OR")) {
                        match options.implicit_indents {
                            ImplicitIndentsMode::Allow => {
                                if scans[next_idx].indent < line.indent + indent_unit {
                                    return true;
                                }
                            }
                            ImplicitIndentsMode::Forbid | ImplicitIndentsMode::Require => {
                                return true;
                            }
                        }
                    }
                    if !on_has_inline && scans[next_idx].indent < line.indent + indent_unit {
                        return true;
                    }
                }
            } else if let Some(next_idx) = next_significant_line(&scans, idx) {
                let next_first = scans[next_idx].words.first().map(String::as_str);
                if matches!(next_first, Some("AND" | "OR")) && scans[next_idx].indent != line.indent
                {
                    return true;
                }
            }
        }

        if options.indented_on_contents && line_contains_inline_on(line.trimmed) {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                if matches!(
                    scans[next_idx].words.first().map(String::as_str),
                    Some("AND" | "OR")
                ) {
                    match options.implicit_indents {
                        ImplicitIndentsMode::Allow => {
                            if scans[next_idx].indent < line.indent + indent_unit {
                                return true;
                            }
                        }
                        ImplicitIndentsMode::Forbid | ImplicitIndentsMode::Require => {
                            return true;
                        }
                    }
                }
            }
        }

        if options.indented_then && matches!(first, Some("THEN")) {
            if let Some(prev_idx) = previous_significant_line(&scans, idx) {
                let prev_first = scans[prev_idx].words.first().map(String::as_str);
                if matches!(prev_first, Some("WHEN")) && line.indent <= scans[prev_idx].indent {
                    return true;
                }
            }
        }

        if !options.indented_then && matches!(first, Some("THEN")) {
            if let Some(prev_idx) = previous_significant_line(&scans, idx) {
                if line.indent > scans[prev_idx].indent + indent_unit {
                    return true;
                }
            }
        }

        if !options.indented_then_contents && matches!(first, Some("THEN")) {
            if let Some(next_idx) = next_significant_line(&scans, idx) {
                if scans[next_idx].indent > line.indent + indent_unit {
                    return true;
                }
            }
        }
    }

    false
}

fn detect_tsql_else_if_successive_violation(sql: &str, tab_space_size: usize) -> bool {
    let lines: Vec<&str> = sql.lines().collect();
    let indent_map = actual_indent_map(sql, tab_space_size);
    let mut scans: Vec<_> = lines
        .iter()
        .enumerate()
        .map(|(line_idx, line)| {
            let trimmed = line.trim_start();
            let is_blank = trimmed.trim().is_empty();
            let words = if is_blank {
                Vec::new()
            } else {
                split_upper_words(trimmed)
            };
            ScanLine {
                trimmed,
                indent: indent_map.get(&line_idx).copied().unwrap_or(0),
                words,
                is_blank,
                is_comment_only: is_comment_line(trimmed),
                inline_case_offset: None,
                prev_significant: None,
                next_significant: None,
            }
        })
        .collect();
    link_significant_lines(&mut scans);

    for idx in 0..scans.len() {
        let line = &scans[idx];
        if line.is_blank || line.is_comment_only {
            continue;
        }

        if !matches!(line.words.first().map(String::as_str), Some("ELSE")) {
            continue;
        }

        if let Some(prev_idx) =
            previous_line_matching(&scans, idx, |f, _| matches!(f, Some("IF" | "ELSE")))
        {
            if line.indent > scans[prev_idx].indent {
                return true;
            }
        }
    }

    false
}

fn split_upper_words(text: &str) -> Vec<String> {
    text.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
        .filter(|word| !word.is_empty())
        .map(|word| word.to_ascii_uppercase())
        .collect()
}

fn is_comment_line(trimmed: &str) -> bool {
    trimmed.starts_with("--")
        || trimmed.starts_with("/*")
        || trimmed.starts_with('*')
        || trimmed.starts_with("*/")
}

fn words_contain_in_order(words: &[String], first: &str, second: &str) -> bool {
    let Some(first_pos) = words.iter().position(|word| word == first) else {
        return false;
    };
    words.iter().skip(first_pos + 1).any(|word| word == second)
}

fn is_content_clause_line(first_word: Option<&str>, trimmed: &str) -> bool {
    matches!(
        first_word,
        Some("SELECT")
            | Some("FROM")
            | Some("WHERE")
            | Some("SET")
            | Some("RETURNING")
            | Some("HAVING")
            | Some("LIMIT")
            | Some("QUALIFY")
            | Some("WINDOW")
            | Some("DECLARE")
            | Some("VALUES")
            | Some("UPDATE")
    ) && split_upper_words(trimmed).len() == 1
}

fn is_join_clause(first_word: Option<&str>, second_word: Option<&str>) -> bool {
    matches!(first_word, Some("JOIN" | "APPLY"))
        || (matches!(
            first_word,
            Some("LEFT" | "RIGHT" | "FULL" | "INNER" | "CROSS" | "OUTER" | "NATURAL")
        ) && matches!(second_word, Some("JOIN" | "APPLY")))
}

fn join_requires_condition(first_word: Option<&str>, second_word: Option<&str>) -> bool {
    matches!(
        first_word,
        Some("JOIN" | "INNER" | "LEFT" | "RIGHT" | "FULL")
    ) || matches!(
        (first_word, second_word),
        (Some("OUTER"), Some("JOIN")) | (Some("NATURAL"), Some("JOIN"))
    )
}

fn is_clause_boundary(first_word: Option<&str>, trimmed: &str) -> bool {
    matches!(
        first_word,
        Some("SELECT")
            | Some("FROM")
            | Some("WHERE")
            | Some("GROUP")
            | Some("ORDER")
            | Some("HAVING")
            | Some("LIMIT")
            | Some("QUALIFY")
            | Some("WINDOW")
            | Some("RETURNING")
            | Some("SET")
            | Some("UPDATE")
            | Some("DELETE")
            | Some("INSERT")
            | Some("MERGE")
            | Some("WITH")
            | Some("JOIN")
            | Some("LEFT")
            | Some("RIGHT")
            | Some("FULL")
            | Some("INNER")
            | Some("OUTER")
            | Some("CROSS")
            | Some("USING")
            | Some("ON")
            | Some("WHEN")
            | Some("THEN")
            | Some("ELSE")
            | Some("END")
    ) || trimmed.starts_with(')')
}

fn next_significant_line(scans: &[ScanLine<'_>], from_idx: usize) -> Option<usize> {
    scans.get(from_idx).and_then(|scan| scan.next_significant)
}

fn previous_significant_line(scans: &[ScanLine<'_>], from_idx: usize) -> Option<usize> {
    scans.get(from_idx).and_then(|scan| scan.prev_significant)
}

fn previous_line_matching(
    scans: &[ScanLine<'_>],
    from_idx: usize,
    predicate: impl Fn(Option<&str>, Option<&str>) -> bool,
) -> Option<usize> {
    (0..from_idx).rev().find(|idx| {
        let first = scans[*idx].words.first().map(String::as_str);
        let second = scans[*idx].words.get(1).map(String::as_str);
        predicate(first, second)
    })
}

fn previous_line_indent_matching(
    scans: &[ScanLine<'_>],
    from_idx: usize,
    predicate: impl Fn(Option<&str>, Option<&str>) -> bool,
) -> Option<usize> {
    (0..from_idx).rev().find_map(|idx| {
        let first = scans[idx].words.first().map(String::as_str);
        let second = scans[idx].words.get(1).map(String::as_str);
        predicate(first, second).then_some(scans[idx].indent)
    })
}

fn line_contains_inline_on(trimmed: &str) -> bool {
    let upper = trimmed.to_ascii_uppercase();
    upper.contains(" ON ") && !upper.starts_with("ON ")
}

fn contains_template_marker(sql: &str) -> bool {
    sql.contains("{{") || sql.contains("{%") || sql.contains("{#")
}

fn is_template_boundary_line(trimmed: &str) -> bool {
    trimmed.starts_with("{%")
        || trimmed.starts_with("{{")
        || trimmed.starts_with("{#")
        || trimmed.starts_with("%}")
        || trimmed.starts_with("}}")
        || trimmed.starts_with("#}")
        || trimmed.ends_with("%}")
        || trimmed.ends_with("}}")
        || trimmed.ends_with("#}")
}

fn template_only_line_flags(lines: &[&str]) -> Vec<bool> {
    let mut flags = vec![false; lines.len()];
    let mut multiline_mode: Option<TemplateMultilineMode> = None;
    let mut non_sql_depth = 0usize;

    for (idx, raw_line) in lines.iter().enumerate() {
        let trimmed = raw_line.trim_start();
        if trimmed.is_empty() {
            continue;
        }

        if let Some(mode) = multiline_mode {
            flags[idx] = true;
            if line_closes_multiline_template(trimmed, mode) {
                multiline_mode = None;
            }
            continue;
        }

        let mut template_only = false;

        // Track `{% ... %}` blocks so macro/set bodies are treated as template-only.
        let control_tags = template_control_tags_in_line(trimmed);
        if !control_tags.is_empty() {
            template_only = true;
            for tag in &control_tags {
                match tag.kind {
                    TemplateControlKind::Open => {
                        if tag
                            .keyword
                            .as_deref()
                            .is_some_and(is_non_sql_template_keyword)
                        {
                            non_sql_depth += 1;
                        }
                    }
                    TemplateControlKind::Close => {
                        if tag
                            .keyword
                            .as_deref()
                            .is_some_and(is_non_sql_template_keyword)
                            && non_sql_depth > 0
                        {
                            non_sql_depth -= 1;
                        }
                    }
                    TemplateControlKind::Mid => {}
                }
            }
        }

        if let Some(mode) = line_starts_multiline_template(trimmed) {
            template_only = true;
            multiline_mode = Some(mode);
        } else if trimmed.starts_with("%}")
            || trimmed.starts_with("}}")
            || trimmed.starts_with("#}")
        {
            template_only = true;
        }

        if (trimmed.starts_with("{{") || trimmed.starts_with("{#") || trimmed.starts_with("{%"))
            && !line_has_sql_outside_template_tags(trimmed)
        {
            template_only = true;
        }

        if non_sql_depth > 0 {
            template_only = true;
        }

        flags[idx] = template_only;
    }

    flags
}

fn line_starts_multiline_template(trimmed: &str) -> Option<TemplateMultilineMode> {
    if trimmed.starts_with("{{") && !trimmed.contains("}}") {
        return Some(TemplateMultilineMode::Expression);
    }
    if trimmed.starts_with("{%") && !trimmed.contains("%}") {
        return Some(TemplateMultilineMode::Statement);
    }
    if trimmed.starts_with("{#") && !trimmed.contains("#}") {
        return Some(TemplateMultilineMode::Comment);
    }
    None
}

fn line_closes_multiline_template(trimmed: &str, mode: TemplateMultilineMode) -> bool {
    match mode {
        TemplateMultilineMode::Expression => trimmed.contains("}}"),
        TemplateMultilineMode::Statement => trimmed.contains("%}"),
        TemplateMultilineMode::Comment => trimmed.contains("#}"),
    }
}

fn is_non_sql_template_keyword(keyword: &str) -> bool {
    matches!(
        keyword,
        "macro" | "set" | "call" | "filter" | "raw" | "test"
    )
}

fn template_control_tags_in_line(line: &str) -> Vec<TemplateControlTag> {
    let mut out = Vec::new();
    let mut cursor = 0usize;

    while let Some(open_rel) = line[cursor..].find("{%") {
        let open = cursor + open_rel;
        let Some(close_rel) = line[open + 2..].find("%}") else {
            break;
        };
        let close = open + 2 + close_rel;
        let mut inner = &line[open + 2..close];
        inner = inner.trim();
        if let Some(stripped) = inner.strip_prefix('-') {
            inner = stripped.trim_start();
        }
        if let Some(stripped) = inner.strip_suffix('-') {
            inner = stripped.trim_end();
        }
        if let Some(first) = inner.split_whitespace().next() {
            let first = first.to_ascii_lowercase();
            if first.starts_with("end") {
                let keyword = first.strip_prefix("end").unwrap_or("").to_string();
                out.push(TemplateControlTag {
                    kind: TemplateControlKind::Close,
                    keyword: (!keyword.is_empty()).then_some(keyword),
                });
            } else if matches!(first.as_str(), "else" | "elif") {
                out.push(TemplateControlTag {
                    kind: TemplateControlKind::Mid,
                    keyword: None,
                });
            } else {
                out.push(TemplateControlTag {
                    kind: TemplateControlKind::Open,
                    keyword: Some(first),
                });
            }
        }
        cursor = close + 2;
    }

    out
}

fn line_has_sql_outside_template_tags(line: &str) -> bool {
    let mut index = 0usize;
    while index < line.len() {
        let rest = &line[index..];
        if rest.starts_with("{{") {
            let Some(close) = rest.find("}}") else {
                return line[..index].chars().any(|ch| !ch.is_whitespace());
            };
            index += close + 2;
            continue;
        }
        if rest.starts_with("{%") {
            let Some(close) = rest.find("%}") else {
                return line[..index].chars().any(|ch| !ch.is_whitespace());
            };
            index += close + 2;
            continue;
        }
        if rest.starts_with("{#") {
            let Some(close) = rest.find("#}") else {
                return line[..index].chars().any(|ch| !ch.is_whitespace());
            };
            index += close + 2;
            continue;
        }

        let Some(ch) = rest.chars().next() else {
            break;
        };
        if !ch.is_whitespace() {
            return true;
        }
        index += ch.len_utf8();
    }

    false
}

fn templated_detection_confident(sql: &str, indent_unit: usize, tab_space_size: usize) -> bool {
    if templated_control_confident_violation(sql, indent_unit, tab_space_size) {
        return true;
    }

    let lines: Vec<&str> = sql.lines().collect();
    if lines.is_empty() {
        return false;
    }

    for idx in 0..lines.len() {
        let line = lines[idx];
        let trimmed = line.trim_start();
        if trimmed.is_empty() || is_comment_line(trimmed) {
            continue;
        }
        let indent = line
            .chars()
            .take_while(|ch| *ch == ' ' || *ch == '\t')
            .count();

        if let Some(prev_idx) = (0..idx).rev().find(|prev| {
            let prev_trim = lines[*prev].trim_start();
            !prev_trim.is_empty() && !is_comment_line(prev_trim)
        }) {
            let prev_trimmed = lines[prev_idx].trim_start();
            let prev_upper = prev_trimmed.to_ascii_uppercase();

            if lines[prev_idx].trim_end().ends_with(',') && indent == 0 {
                return true;
            }

            if is_content_clause_line(
                split_upper_words(&prev_upper).first().map(String::as_str),
                &prev_upper,
            ) && indent == 0
            {
                return true;
            }
        }

        if trimmed.starts_with("{{")
            && indent == 0
            && (0..idx)
                .rev()
                .find(|prev| {
                    let prev_trim = lines[*prev].trim_start();
                    !prev_trim.is_empty() && !is_comment_line(prev_trim)
                })
                .is_some_and(|prev_idx| lines[prev_idx].trim_end().ends_with(','))
        {
            return true;
        }
    }

    false
}

fn templated_control_confident_violation(
    sql: &str,
    indent_unit: usize,
    tab_space_size: usize,
) -> bool {
    let lines: Vec<&str> = sql.lines().collect();
    if lines.is_empty() {
        return false;
    }

    let template_only_lines = template_only_line_flags(&lines);
    let indent_map = actual_indent_map(sql, tab_space_size);
    let mut sql_template_block_indents: Vec<usize> = Vec::new();

    for (idx, line) in lines.iter().enumerate() {
        let trimmed = line.trim_start();
        if trimmed.trim().is_empty() {
            continue;
        }

        let indent = indent_map.get(&idx).copied().unwrap_or(0);
        let controls = template_control_tags_in_line(trimmed);
        if !controls.is_empty() {
            for tag in controls {
                match tag.kind {
                    TemplateControlKind::Open => {
                        if tag
                            .keyword
                            .as_deref()
                            .is_none_or(|keyword| !is_non_sql_template_keyword(keyword))
                        {
                            sql_template_block_indents.push(indent);
                        }
                    }
                    TemplateControlKind::Mid => {
                        if let Some(expected_indent) = sql_template_block_indents.last() {
                            if indent != *expected_indent {
                                return true;
                            }
                        }
                    }
                    TemplateControlKind::Close => {
                        if tag
                            .keyword
                            .as_deref()
                            .is_none_or(|keyword| !is_non_sql_template_keyword(keyword))
                        {
                            if let Some(open_indent) = sql_template_block_indents.pop() {
                                if indent != open_indent {
                                    return true;
                                }
                            }
                        }
                    }
                }
            }
            continue;
        }

        if template_only_lines.get(idx).copied().unwrap_or(false) {
            continue;
        }

        if !sql_template_block_indents.is_empty() {
            let required_indent = sql_template_block_indents[0] + indent_unit;
            if indent < required_indent {
                return true;
            }
        }
    }

    false
}

/// Build the indent string for a given target width.
fn make_indent(
    width: usize,
    _indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) -> String {
    if width == 0 {
        return String::new();
    }
    match indent_style {
        IndentStyle::Spaces => " ".repeat(width),
        IndentStyle::Tabs => {
            let tab_width = tab_space_size.max(1);
            let tab_count = width.div_ceil(tab_width);
            "\t".repeat(tab_count)
        }
    }
}

/// Per-line summary of token structure.
struct LineTokenInfo {
    /// True if the line starts with a top-level clause keyword.
    starts_with_clause_keyword: bool,
    /// True if the line starts with a content-bearing clause keyword that is
    /// alone on the line. Content-bearing keywords are those whose content
    /// should be indented on the following line (SELECT, FROM, WHERE, SET,
    /// RETURNING, HAVING, LIMIT, QUALIFY, WINDOW, DECLARE). Keywords like
    /// WITH, CREATE, UNION are excluded because their "content" is other
    /// clause-level constructs, not indented content.
    is_standalone_content_clause: bool,
    /// True if the line contains only comment tokens.
    is_comment_only: bool,
    /// True if the line starts with a SELECT modifier (DISTINCT, ALL) that
    /// belongs with a preceding SELECT keyword rather than being content
    /// that should be indented.
    starts_with_select_modifier: bool,
}

/// Keywords whose content on the following line should be indented.
fn is_content_bearing_clause(kw: Keyword) -> bool {
    matches!(
        kw,
        Keyword::SELECT
            | Keyword::FROM
            | Keyword::WHERE
            | Keyword::SET
            | Keyword::RETURNING
            | Keyword::HAVING
            | Keyword::LIMIT
            | Keyword::QUALIFY
            | Keyword::WINDOW
            | Keyword::DECLARE
            | Keyword::VALUES
            | Keyword::UPDATE
    )
}

/// Build per-line token info from the token stream.
fn build_line_token_infos(tokens: &[StructuralToken]) -> BTreeMap<usize, LineTokenInfo> {
    let mut result: BTreeMap<usize, LineTokenInfo> = BTreeMap::new();

    // Group non-trivia tokens by line.
    let mut tokens_by_line: BTreeMap<usize, Vec<&StructuralToken>> = BTreeMap::new();
    for token in tokens {
        if is_whitespace_or_newline(&token.token) {
            continue;
        }
        tokens_by_line.entry(token.line).or_default().push(token);
    }

    // Track preceding keyword for GROUP BY / ORDER BY detection.
    let mut prev_keyword: Option<Keyword> = None;

    for (&line, line_tokens) in &tokens_by_line {
        let first = &line_tokens[0];
        let starts_with_clause = is_first_token_clause_keyword(first, prev_keyword);

        // Check if the first keyword is content-bearing.
        let first_is_content_bearing = match &first.token {
            Token::Word(w) => is_content_bearing_clause(w.keyword),
            _ => false,
        };

        // A clause keyword is "standalone" if all non-trivia tokens on the
        // line are clause keywords / modifiers / comments / semicolons.
        let is_standalone = starts_with_clause && first_is_content_bearing && {
            line_tokens.iter().all(|t| match &t.token {
                Token::Word(w) => {
                    is_clause_keyword_word(w.keyword)
                        || w.keyword == Keyword::NoKeyword && is_join_modifier(&w.value)
                }
                Token::SemiColon => true,
                _ => is_comment_token(&t.token),
            })
        };

        let comment_only = line_tokens.iter().all(|t| is_comment_token(&t.token));

        let starts_with_select_modifier = match &first.token {
            Token::Word(w) => matches!(w.keyword, Keyword::DISTINCT | Keyword::ALL),
            _ => false,
        };

        result.insert(
            line,
            LineTokenInfo {
                starts_with_clause_keyword: starts_with_clause,
                is_standalone_content_clause: is_standalone,
                is_comment_only: comment_only,
                starts_with_select_modifier,
            },
        );

        // Update prev_keyword from last keyword on this line.
        for t in line_tokens.iter().rev() {
            if let Token::Word(w) = &t.token {
                if w.keyword != Keyword::NoKeyword {
                    prev_keyword = Some(w.keyword);
                    break;
                }
            }
        }
    }

    result
}

fn is_first_token_clause_keyword(token: &StructuralToken, prev_keyword: Option<Keyword>) -> bool {
    match &token.token {
        Token::Word(w) => is_top_level_clause_keyword(w.keyword, prev_keyword),
        _ => false,
    }
}

fn is_comment_token(token: &Token) -> bool {
    matches!(
        token,
        Token::Whitespace(Whitespace::SingleLineComment { .. })
            | Token::Whitespace(Whitespace::MultiLineComment(_))
    )
}

/// Tokenize SQL for structural analysis. Falls back to statement-level
/// tokenization when document tokens are not available.
fn tokenize_for_structural_check(sql: &str, ctx: &LintContext) -> Option<Vec<StructuralToken>> {
    // Fall back to statement-level tokenization (document tokens use
    // 1-indexed lines which makes correlation harder; local tokenization
    // gives 0-indexed consistency).
    let dialect = ctx.dialect().to_sqlparser_dialect();
    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
    let Ok(tokens) = tokenizer.tokenize_with_location() else {
        return None;
    };

    Some(
        tokens
            .into_iter()
            .filter_map(|t| {
                let line = t.span.start.line as usize;
                let col = t.span.start.column as usize;
                let offset = line_col_to_offset(sql, line, col)?;
                Some(StructuralToken {
                    token: t.token,
                    offset,
                    line: line.saturating_sub(1),
                })
            })
            .collect(),
    )
}

#[derive(Clone)]
struct StructuralToken {
    token: Token,
    #[allow(dead_code)]
    offset: usize,
    line: usize,
}

/// Returns true if the keyword starts a top-level SQL clause.
fn is_top_level_clause_keyword(kw: Keyword, _prev_keyword: Option<Keyword>) -> bool {
    is_clause_keyword_word(kw)
}

/// Core set of SQL clause keywords that establish a new indent level.
fn is_clause_keyword_word(kw: Keyword) -> bool {
    matches!(
        kw,
        Keyword::SELECT
            | Keyword::FROM
            | Keyword::WHERE
            | Keyword::SET
            | Keyword::UPDATE
            | Keyword::INSERT
            | Keyword::DELETE
            | Keyword::MERGE
            | Keyword::USING
            | Keyword::INTO
            | Keyword::VALUES
            | Keyword::RETURNING
            | Keyword::HAVING
            | Keyword::LIMIT
            | Keyword::WINDOW
            | Keyword::QUALIFY
            | Keyword::WITH
            | Keyword::BEGIN
            | Keyword::DECLARE
            | Keyword::IF
            | Keyword::RETURNS
            | Keyword::CREATE
            | Keyword::DROP
            | Keyword::ON
            | Keyword::JOIN
            | Keyword::INNER
            | Keyword::LEFT
            | Keyword::RIGHT
            | Keyword::FULL
            | Keyword::CROSS
            | Keyword::OUTER
    )
}

fn is_join_modifier(word: &str) -> bool {
    let upper = word.to_ascii_uppercase();
    matches!(
        upper.as_str(),
        "JOIN" | "INNER" | "LEFT" | "RIGHT" | "FULL" | "CROSS" | "OUTER" | "APPLY"
    )
}

fn is_whitespace_or_newline(token: &Token) -> bool {
    matches!(
        token,
        Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
    )
}

/// Build a map of line_index -> actual indent width from the SQL text.
fn actual_indent_map(sql: &str, tab_space_size: usize) -> BTreeMap<usize, usize> {
    let mut result = BTreeMap::new();
    for (idx, line) in sql.lines().enumerate() {
        if line.trim().is_empty() {
            continue;
        }
        let indent = leading_indent_from_prefix(line, tab_space_size);
        result.insert(idx, indent.width);
    }
    result
}

// ---------------------------------------------------------------------------
// Original indent snapshot and autofix infrastructure
// ---------------------------------------------------------------------------

#[derive(Clone, Copy)]
struct LeadingIndent {
    width: usize,
    space_count: usize,
    tab_count: usize,
    has_mixed_indent_chars: bool,
}

#[derive(Clone, Copy)]
struct LineIndentSnapshot {
    line_index: usize,
    indent: LeadingIndent,
}

struct StatementLineInfo {
    start: usize,
    indent_end: usize,
}

#[derive(Clone)]
struct Lt02AutofixEdit {
    start: usize,
    end: usize,
    replacement: String,
}

fn should_prefer_lt02_edit(candidate: &Lt02AutofixEdit, current: &Lt02AutofixEdit) -> bool {
    // Mirror planner ordering for same-start conflicts:
    // lower `end` wins, then lexicographically lower replacement.
    if candidate.end != current.end {
        return candidate.end < current.end;
    }

    candidate.replacement < current.replacement
}

fn should_prefer_lt02_patch(candidate: &IssuePatchEdit, current: &IssuePatchEdit) -> bool {
    // Mirror planner ordering for same-start conflicts.
    if candidate.span.end != current.span.end {
        return candidate.span.end < current.span.end;
    }

    candidate.replacement < current.replacement
}

fn collapse_lt02_autofix_edits_by_start(edits: Vec<Lt02AutofixEdit>) -> Vec<Lt02AutofixEdit> {
    let mut by_start: BTreeMap<usize, Lt02AutofixEdit> = BTreeMap::new();
    for edit in edits {
        match by_start.entry(edit.start) {
            std::collections::btree_map::Entry::Vacant(entry) => {
                entry.insert(edit);
            }
            std::collections::btree_map::Entry::Occupied(mut entry) => {
                if should_prefer_lt02_edit(&edit, entry.get()) {
                    entry.insert(edit);
                }
            }
        }
    }
    by_start.into_values().collect()
}

fn line_indent_snapshots(ctx: &LintContext, tab_space_size: usize) -> Vec<LineIndentSnapshot> {
    if let Some(tokens) = tokenize_with_offsets_for_context(ctx) {
        let statement_start_line = offset_to_line(ctx.sql, ctx.statement_range.start);
        let mut first_token_by_line: BTreeMap<usize, usize> = BTreeMap::new();
        for token in &tokens {
            if token.start < ctx.statement_range.start || token.start >= ctx.statement_range.end {
                continue;
            }
            if is_whitespace_token(&token.token) {
                continue;
            }
            first_token_by_line
                .entry(token.start_line)
                .or_insert(token.start);
        }

        return first_token_by_line
            .into_iter()
            .map(|(line, token_start)| {
                let line_start = ctx.sql[..token_start]
                    .rfind('\n')
                    .map_or(0, |index| index + 1);
                let leading = &ctx.sql[line_start..token_start];
                LineIndentSnapshot {
                    line_index: line.saturating_sub(statement_start_line),
                    indent: leading_indent_from_prefix(leading, tab_space_size),
                }
            })
            .collect();
    }

    let sql = ctx.statement_sql();
    let Some(tokens) = tokenize_with_locations(sql, ctx.dialect()) else {
        return sql
            .lines()
            .enumerate()
            .filter_map(|(line_index, line)| {
                if line.trim().is_empty() {
                    return None;
                }
                Some(LineIndentSnapshot {
                    line_index,
                    indent: leading_indent(line, tab_space_size),
                })
            })
            .collect();
    };

    let mut first_token_by_line: std::collections::BTreeMap<usize, usize> =
        std::collections::BTreeMap::new();
    for token in &tokens {
        if is_whitespace_token(&token.token) {
            continue;
        }
        let line = token.span.start.line as usize;
        let column = token.span.start.column as usize;
        first_token_by_line.entry(line).or_insert(column);
    }

    first_token_by_line
        .into_iter()
        .filter_map(|(line, column)| {
            let line_start = line_col_to_offset(sql, line, 1)?;
            let token_start = line_col_to_offset(sql, line, column)?;
            let leading = &sql[line_start..token_start];
            Some(LineIndentSnapshot {
                line_index: line.saturating_sub(1),
                indent: leading_indent_from_prefix(leading, tab_space_size),
            })
        })
        .collect()
}

#[derive(Clone)]
struct LocatedToken {
    token: Token,
    start: usize,
    start_line: usize,
}

fn tokenize_with_locations(sql: &str, dialect: Dialect) -> Option<Vec<TokenWithSpan>> {
    let dialect = dialect.to_sqlparser_dialect();
    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
    tokenizer.tokenize_with_location().ok()
}

fn tokenize_with_offsets_for_context(ctx: &LintContext) -> Option<Vec<LocatedToken>> {
    ctx.with_document_tokens(|tokens| {
        if tokens.is_empty() {
            return None;
        }

        Some(
            tokens
                .iter()
                .filter_map(|token| {
                    token_with_span_offsets(ctx.sql, token).map(|(start, _end)| LocatedToken {
                        token: token.token.clone(),
                        start,
                        start_line: token.span.start.line as usize,
                    })
                })
                .collect::<Vec<_>>(),
        )
    })
}

fn is_whitespace_token(token: &Token) -> bool {
    matches!(
        token,
        Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
    )
}

fn leading_indent(line: &str, tab_space_size: usize) -> LeadingIndent {
    leading_indent_from_prefix(line, tab_space_size)
}

fn leading_indent_from_prefix(prefix: &str, tab_space_size: usize) -> LeadingIndent {
    let mut width = 0usize;
    let mut space_count = 0usize;
    let mut tab_count = 0usize;

    for ch in prefix.chars() {
        match ch {
            ' ' => {
                space_count += 1;
                width += 1;
            }
            '\t' => {
                tab_count += 1;
                width += tab_space_size;
            }
            _ => break,
        }
    }

    LeadingIndent {
        width,
        space_count,
        tab_count,
        has_mixed_indent_chars: space_count > 0 && tab_count > 0,
    }
}

fn indentation_autofix_edits(
    statement_sql: &str,
    snapshots: &[LineIndentSnapshot],
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) -> Vec<Lt02AutofixEdit> {
    let line_infos = statement_line_infos(statement_sql);
    let mut edits = Vec::new();

    for snapshot in snapshots {
        let Some(line_info) = line_infos.get(snapshot.line_index) else {
            continue;
        };
        let start = line_info.start;
        let end = line_info.start + line_info.indent_end;
        if end > statement_sql.len() || start > end {
            continue;
        }

        let current_prefix = &statement_sql[start..end];
        let replacement = if snapshot.line_index == 0 {
            String::new()
        } else {
            normalized_indent_replacement(
                snapshot.indent.width,
                indent_unit,
                tab_space_size,
                indent_style,
            )
        };

        if replacement != current_prefix {
            edits.push(Lt02AutofixEdit {
                start,
                end,
                replacement,
            });
        }
    }

    edits
}

fn statement_line_infos(sql: &str) -> Vec<StatementLineInfo> {
    let mut infos = Vec::new();
    let mut line_start = 0usize;

    for segment in sql.split_inclusive('\n') {
        let line = segment.strip_suffix('\n').unwrap_or(segment);
        let indent_end = line
            .char_indices()
            .find_map(|(index, ch)| {
                if matches!(ch, ' ' | '\t') {
                    None
                } else {
                    Some(index)
                }
            })
            .unwrap_or(line.len());
        infos.push(StatementLineInfo {
            start: line_start,
            indent_end,
        });
        line_start += segment.len();
    }

    infos
}

fn statement_line_index_for_offset(
    line_infos: &[StatementLineInfo],
    offset: usize,
) -> Option<usize> {
    if line_infos.is_empty() {
        return None;
    }

    let idx = line_infos.partition_point(|line| line.start <= offset);
    let line_idx = idx
        .saturating_sub(1)
        .min(line_infos.len().saturating_sub(1));
    Some(line_idx)
}

fn normalized_indent_replacement(
    width: usize,
    indent_unit: usize,
    tab_space_size: usize,
    indent_style: IndentStyle,
) -> String {
    if width == 0 {
        return String::new();
    }

    let rounded = rounded_indent_width(width, indent_unit.max(1));
    if rounded == 0 {
        return String::new();
    }

    match indent_style {
        IndentStyle::Spaces => " ".repeat(rounded),
        IndentStyle::Tabs => {
            let tab_width = tab_space_size.max(1);
            let tab_count = rounded.div_ceil(tab_width).max(1);
            "\t".repeat(tab_count)
        }
    }
}

fn rounded_indent_width(width: usize, indent_unit: usize) -> usize {
    if width == 0 || indent_unit == 0 {
        return width;
    }

    if width.is_multiple_of(indent_unit) {
        return width;
    }

    let down = (width / indent_unit) * indent_unit;
    let up = down + indent_unit;
    if down == 0 {
        up
    } else if width - down <= up - width {
        down
    } else {
        up
    }
}

fn ceil_indent_width(width: usize, indent_unit: usize) -> usize {
    if width == 0 || indent_unit == 0 {
        return width;
    }
    width.div_ceil(indent_unit) * indent_unit
}

fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
    if line == 0 || column == 0 {
        return None;
    }

    let mut current_line = 1usize;
    let mut current_col = 1usize;

    for (offset, ch) in sql.char_indices() {
        if current_line == line && current_col == column {
            return Some(offset);
        }

        if ch == '\n' {
            current_line += 1;
            current_col = 1;
        } else {
            current_col += 1;
        }
    }

    if current_line == line && current_col == column {
        return Some(sql.len());
    }

    None
}

fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
    let start = line_col_to_offset(
        sql,
        token.span.start.line as usize,
        token.span.start.column as usize,
    )?;
    let end = line_col_to_offset(
        sql,
        token.span.end.line as usize,
        token.span.end.column as usize,
    )?;
    Some((start, end))
}

fn offset_to_line(sql: &str, offset: usize) -> usize {
    1 + sql[..offset.min(sql.len())]
        .chars()
        .filter(|ch| *ch == '\n')
        .count()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::linter::config::LintConfig;
    use crate::linter::rule::with_active_dialect;
    use crate::parser::parse_sql;
    use crate::types::{Dialect, IssueAutofixApplicability};

    fn run(sql: &str) -> Vec<Issue> {
        run_with_config(sql, LintConfig::default())
    }

    fn run_with_config(sql: &str, config: LintConfig) -> Vec<Issue> {
        let statements = parse_sql(sql).expect("parse");
        let rule = LayoutIndent::from_config(&config);
        statements
            .iter()
            .enumerate()
            .flat_map(|(index, statement)| {
                rule.check(
                    statement,
                    &LintContext {
                        sql,
                        statement_range: 0..sql.len(),
                        statement_index: index,
                    },
                )
            })
            .collect()
    }

    fn run_postgres(sql: &str) -> Vec<Issue> {
        with_active_dialect(Dialect::Postgres, || run(sql))
    }

    fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
        let autofix = issue.autofix.as_ref()?;
        let mut out = sql.to_string();
        let mut edits = autofix.edits.clone();
        edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
        for edit in edits.into_iter().rev() {
            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
        }
        Some(out)
    }

    fn apply_all_issue_autofixes(sql: &str, issues: &[Issue]) -> Option<String> {
        let mut all_edits = Vec::new();
        for issue in issues {
            if let Some(autofix) = issue.autofix.as_ref() {
                all_edits.extend(autofix.edits.clone());
            }
        }
        if all_edits.is_empty() {
            return None;
        }

        all_edits.sort_by_key(|edit| (edit.span.start, edit.span.end, edit.replacement.clone()));
        all_edits.dedup_by(|left, right| {
            left.span.start == right.span.start
                && left.span.end == right.span.end
                && left.replacement == right.replacement
        });

        let mut out = sql.to_string();
        for edit in all_edits.into_iter().rev() {
            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
        }
        Some(out)
    }

    fn normalize_whitespace(text: &str) -> String {
        text.split_whitespace().collect::<Vec<_>>().join(" ")
    }

    #[test]
    fn flags_odd_indent_width() {
        let issues = run("SELECT a\n   , b\nFROM t");
        assert_eq!(issues.len(), 1);
        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
    }

    #[test]
    fn odd_indent_width_emits_safe_autofix() {
        let sql = "SELECT a\n   , b\nFROM t";
        let issues = run(sql);
        assert_eq!(issues.len(), 1);
        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
        assert_eq!(fixed, "SELECT a\n    , b\nFROM t");
    }

    #[test]
    fn flags_first_line_indentation() {
        let issues = run("   SELECT 1");
        assert_eq!(issues.len(), 1);
        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
    }

    #[test]
    fn first_line_indentation_emits_safe_autofix_when_editable() {
        let sql = "   SELECT 1";
        let issues = run(sql);
        assert_eq!(issues.len(), 1);
        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
        assert_eq!(fixed, "SELECT 1");
    }

    #[test]
    fn does_not_flag_even_indent_width() {
        assert!(run("SELECT a\n    , b\nFROM t").is_empty());
    }

    #[test]
    fn flags_mixed_tab_and_space_indentation() {
        let issues = run("SELECT a\n \t, b\nFROM t");
        assert_eq!(issues.len(), 1);
        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
    }

    #[test]
    fn tab_space_size_config_is_applied_for_tab_indentation_width() {
        let config = LintConfig {
            enabled: true,
            disabled_rules: vec![],
            rule_configs: std::collections::BTreeMap::from([(
                "layout.indent".to_string(),
                serde_json::json!({"tab_space_size": 2, "indent_unit": "tab"}),
            )]),
        };
        let issues = run_with_config("SELECT a\n\t, b\nFROM t", config);
        assert!(issues.is_empty());
    }

    #[test]
    fn tab_indent_unit_disallows_space_indent() {
        let config = LintConfig {
            enabled: true,
            disabled_rules: vec![],
            rule_configs: std::collections::BTreeMap::from([(
                "layout.indent".to_string(),
                serde_json::json!({"indent_unit": "tab"}),
            )]),
        };
        let issues = run_with_config("SELECT a\n    , b\nFROM t", config);
        assert_eq!(issues.len(), 1);
        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
    }

    #[test]
    fn tab_indent_style_emits_tab_autofix() {
        let config = LintConfig {
            enabled: true,
            disabled_rules: vec![],
            rule_configs: std::collections::BTreeMap::from([(
                "layout.indent".to_string(),
                serde_json::json!({"indent_unit": "tab"}),
            )]),
        };
        let sql = "SELECT a\n    , b\nFROM t";
        let issues = run_with_config(sql, config);
        assert_eq!(issues.len(), 1);
        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
        assert_eq!(fixed, "SELECT a\n\t, b\nFROM t");
    }

    #[test]
    fn indentation_section_options_are_supported() {
        let config = LintConfig {
            enabled: true,
            disabled_rules: vec![],
            rule_configs: std::collections::BTreeMap::from([(
                "indentation".to_string(),
                serde_json::json!({"indent_unit": "tab", "tab_space_size": 2}),
            )]),
        };
        let issues = run_with_config("SELECT a\n\t, b\nFROM t", config);
        assert!(issues.is_empty());
    }

    #[test]
    fn indentation_on_comment_line_is_checked() {
        let issues = run("SELECT 1\n   -- comment\nFROM t");
        assert_eq!(issues.len(), 1);
        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
    }

    #[test]
    fn first_line_indent_outside_statement_range_is_report_only() {
        let sql = "   SELECT 1";
        let statements = parse_sql(sql).expect("parse");
        let rule = LayoutIndent::default();
        let issues = rule.check(
            &statements[0],
            &LintContext {
                sql,
                statement_range: 3..sql.len(),
                statement_index: 0,
            },
        );
        assert_eq!(issues.len(), 1);
        assert!(
            issues[0].autofix.is_none(),
            "non-editable first-line prefix should remain report-only"
        );
    }

    #[test]
    fn fragmented_non_semicolon_statement_triggers_first_line_indent_guard() {
        let sql = "SELECT\n    a";
        assert!(
            ignore_first_line_indent_for_fragmented_statement(&LintContext {
                sql,
                statement_range: 7..sql.len(),
                statement_index: 1,
            }),
            "fragmented follow-on statement chunks should ignore first-line LT02"
        );
    }

    // Structural indentation tests.

    #[test]
    fn flags_clause_content_not_indented_under_update() {
        // UPDATE\nfoo\nSET\nupdated = now()\nWHERE\n    bar = '';
        // "foo" should be indented under UPDATE, "updated" under SET.
        let issues = run("UPDATE\nfoo\nSET\nupdated = now()\nWHERE\n    bar = '';");
        assert_eq!(issues.len(), 1, "should flag unindented clause contents");
        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
    }

    #[test]
    fn flags_unindented_from_content() {
        // FROM\nmy_tbl should flag because my_tbl is not indented.
        let issues = run("SELECT\n    a,\n    b\nFROM\nmy_tbl");
        assert_eq!(issues.len(), 1);
        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
    }

    #[test]
    fn accepts_properly_indented_clauses() {
        // All clause contents properly indented.
        let issues = run("SELECT\n    a,\n    b\nFROM\n    my_tbl\nWHERE\n    a = 1");
        assert!(issues.is_empty(), "properly indented SQL should not flag");
    }

    #[test]
    fn flags_trailing_comment_wrong_indent() {
        // Trailing comments at deepening indent levels after content at
        // indent 0. Both `-- foo` (indent 4) and `-- bar` (indent 8) are
        // deeper than `SELECT 1` (indent 0).
        let issues = run("SELECT 1\n    -- foo\n        -- bar");
        assert_eq!(issues.len(), 1);
        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
    }

    #[test]
    fn accepts_properly_indented_trailing_comment() {
        // Comment at same indent as the content line before it is fine.
        let issues = run("SELECT\n    a\n    -- explains next col\n    , b\nFROM t");
        assert!(issues.is_empty());
    }

    // Structural autofix tests.

    #[test]
    fn structural_autofix_indents_content_under_clause_keyword() {
        // RETURNING\nupdated should fix to RETURNING\n    updated
        let sql = "INSERT INTO foo (updated)\nVALUES (now())\nRETURNING\nupdated;";
        let issues = run(sql);
        assert_eq!(issues.len(), 1);
        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
        assert_eq!(
            fixed,
            "INSERT INTO foo (updated)\nVALUES (now())\nRETURNING\n    updated;"
        );
    }

    #[test]
    fn structural_autofix_indents_update_content() {
        // UPDATE\nfoo -> UPDATE\n    foo
        let sql = "UPDATE\nfoo\nSET\nupdated = now()\nWHERE\n    bar = ''";
        let issues = run(sql);
        assert_eq!(issues.len(), 1);
        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
        assert_eq!(
            fixed,
            "UPDATE\n    foo\nSET\n    updated = now()\nWHERE\n    bar = ''"
        );
    }

    #[test]
    fn structural_autofix_indents_from_content() {
        let sql = "SELECT\n    a,\n    b\nFROM\nmy_tbl";
        let issues = run(sql);
        assert_eq!(issues.len(), 1);
        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
        assert_eq!(fixed, "SELECT\n    a,\n    b\nFROM\n    my_tbl");
    }

    #[test]
    fn structural_autofix_fixes_trailing_comment_indent() {
        let sql = "SELECT 1\n    -- foo\n        -- bar";
        let issues = run(sql);
        assert_eq!(issues.len(), 1);
        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
        // Both comments should be at indent 0 (same as content line).
        assert_eq!(fixed, "SELECT 1\n-- foo\n-- bar");
    }

    #[test]
    fn structural_autofix_does_not_add_parenthesis_spacing() {
        let sql = "SELECT coalesce(foo,\n              bar)\n   FROM tbl";
        let issues = run(sql);
        assert_eq!(issues.len(), 1);
        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
        assert_eq!(
            normalize_whitespace(&fixed),
            "SELECT coalesce(foo, bar) FROM tbl"
        );
    }

    #[test]
    fn detects_tsql_successive_else_if_indent_violation() {
        let sql = "IF (1 > 1)\n    PRINT 'A';\n    ELSE IF (2 > 2)\n        PRINT 'B';\n        ELSE IF (3 > 3)\n            PRINT 'C';\n            ELSE\n                PRINT 'D';\n";
        assert!(detect_tsql_else_if_successive_violation(sql, 4));
    }

    #[test]
    fn allows_tsql_proper_else_if_chain() {
        let sql = "IF (1 > 1)\n    PRINT 'A';\nELSE IF (2 > 2)\n    PRINT 'B';\nELSE IF (3 > 3)\n    PRINT 'C';\nELSE\n    PRINT 'D';\n";
        assert!(!detect_tsql_else_if_successive_violation(sql, 4));
    }

    #[test]
    fn mssql_partial_parse_fallback_detects_successive_else_if_violation() {
        let sql = "IF (1 > 1)\n    PRINT 'A';\n    ELSE IF (2 > 2)\n        PRINT 'B';\n        ELSE IF (3 > 3)\n            PRINT 'C';\n            ELSE\n                PRINT 'D';\n";
        let first_statement = "IF (1 > 1)\n    PRINT 'A';";
        let placeholder = parse_sql("SELECT 1").expect("parse placeholder");
        let rule = LayoutIndent::default();
        let issues = with_active_dialect(Dialect::Mssql, || {
            rule.check(
                &placeholder[0],
                &LintContext {
                    sql,
                    statement_range: 0..first_statement.len(),
                    statement_index: 0,
                },
            )
        });
        assert_eq!(issues.len(), 1);
        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
    }

    #[test]
    fn postgres_where_inline_condition_chain_autofixes() {
        let sql = "SELECT\n    a\nFROM t\nWHERE a = 1\nAND b = 2";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "SELECT\n    a\nFROM t\nWHERE\n    a = 1\n    AND b = 2"
        );
    }

    #[test]
    fn postgres_having_inline_condition_chain_autofixes() {
        let sql = "SELECT\n    workspace_id\nFROM t\nHAVING SUM(cost_usd) > 0\n    AND AVG(cost_usd) < 10";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "SELECT\n    workspace_id\nFROM t\nHAVING\n    SUM(cost_usd) > 0\n    AND AVG(cost_usd) < 10"
        );
    }

    #[test]
    fn postgres_where_inline_operator_continuation_autofixes() {
        let sql = "SELECT\n    1\nFROM t\nWHERE is_active\n= TRUE";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "SELECT\n    1\nFROM t\nWHERE\n    is_active\n    = TRUE"
        );
    }

    #[test]
    fn postgres_standalone_where_block_autofixes_with_other_lt02_violation() {
        let sql = "SELECT\n   1\nFROM t\nWHERE\na = 1\nAND b = 2";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "SELECT\n    1\nFROM t\nWHERE\n    a = 1\n    AND b = 2"
        );
    }

    #[test]
    fn postgres_standalone_where_block_under_exists_autofixes() {
        let sql = "SELECT\n    1\nFROM t\nWHERE\n    EXISTS (\n        SELECT 1 FROM ledger.cluster_live_status cls\n        JOIN ledger.cluster c ON c.cluster_id = cls.cluster_id\n        WHERE\n          c.id = i.subject_id::uuid\n          AND cls.state != 'RUNNING'\n    )";
        let mut postgres_only = sql.to_string();
        let mut postgres_only_edits = collapse_lt02_autofix_edits_by_start(
            postgres_keyword_break_and_indent_edits(sql, 4, 4, IndentStyle::Spaces),
        );
        let line_infos = statement_line_infos(sql);
        let debug_edits: Vec<String> = postgres_only_edits
            .iter()
            .map(|edit| {
                let line = statement_line_index_for_offset(&line_infos, edit.start)
                    .map(|line_idx| line_idx + 1)
                    .unwrap_or(0);
                format!(
                    "line={line} start={} end={} replacement={:?}",
                    edit.start,
                    edit.end,
                    edit.replacement.replace(' ', "·")
                )
            })
            .collect();
        postgres_only_edits.sort_by(|left, right| right.start.cmp(&left.start));
        for edit in postgres_only_edits {
            if edit.start <= edit.end && edit.end <= postgres_only.len() {
                postgres_only.replace_range(edit.start..edit.end, &edit.replacement);
            }
        }
        assert!(
            postgres_only.contains(
                "        WHERE\n            c.id = i.subject_id::uuid\n            AND cls.state != 'RUNNING'"
            ),
            "postgres structural edits should indent standalone WHERE block, got:\n{postgres_only}\nedits={debug_edits:#?}"
        );

        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert!(
            fixed.contains(
                "        WHERE\n            c.id = i.subject_id::uuid\n            AND cls.state != 'RUNNING'"
            ),
            "nested standalone WHERE block should indent condition lines under WHERE, got:\n{fixed}"
        );
    }

    #[test]
    fn postgres_trailing_as_alias_break_autofixes() {
        let sql = "SELECT\n    o.id AS\n    org_unit_id\nFROM t AS o";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "SELECT\n    o.id\n        AS\n        org_unit_id\nFROM t AS o"
        );
    }

    #[test]
    fn postgres_on_conflict_set_block_autofixes() {
        let sql = "INSERT INTO foo (id, value)\nVALUES (1, 'x')\nON CONFLICT (id) DO UPDATE\nSET value = EXCLUDED.value,\nupdated_at = NOW()";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "INSERT INTO foo (id, value)\nVALUES (1, 'x')\nON CONFLICT (id) DO UPDATE\n    SET\n        value = EXCLUDED.value,\n        updated_at = NOW()"
        );
    }

    #[test]
    fn postgres_select_after_union_operator_autofixes() {
        let sql = "SELECT\n    1 AS a\nUNION ALL\nSELECT a, b";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(fixed, "SELECT\n    1 AS a\nUNION ALL\nSELECT\n    a, b");
    }

    #[test]
    fn postgres_unioned_identifier_does_not_trigger_union_select_break() {
        let sql = "WITH unioned AS (\n    SELECT a, b\n    FROM t\n)\nSELECT\n    a\nFROM unioned";
        let issues = run_postgres(sql);
        assert!(
            issues.is_empty(),
            "UNIONED identifier should not be treated like a UNION set operator"
        );
    }

    #[test]
    fn postgres_where_block_with_nested_subquery_autofixes() {
        let sql =
            "SELECT\n    1\nFROM t\nWHERE a = 1\nAND b IN (\nSELECT 1\nWHERE TRUE\n)\nAND c = 2";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "SELECT\n    1\nFROM t\nWHERE\n    a = 1\n    AND b IN (\n        SELECT 1\n        WHERE TRUE\n    )\n    AND c = 2"
        );
    }

    #[test]
    fn postgres_inline_join_on_with_operator_continuation_autofixes() {
        let sql = "SELECT\n    1\nFROM foo AS f\nINNER\nJOIN bar AS b ON f.id = b.id AND b.is_current\n= TRUE";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "SELECT\n    1\nFROM foo AS f\nINNER\nJOIN bar AS b\n    ON f.id = b.id AND b.is_current\n        = TRUE"
        );
    }

    #[test]
    fn postgres_standalone_on_block_with_nested_parens_autofixes() {
        let sql = "SELECT\n    1\nFROM foo AS c\nLEFT JOIN bar AS v\n    ON\n    -- Non-PIPELINE: exact version_key match\n        (c.cluster_source <> 'PIPELINE' AND c.dbr_version = v.version_key)\n    OR\n    -- PIPELINE: match on main_version + photon\n    (c.cluster_source = 'PIPELINE'\n    AND c.parsed_main_version = v.main_version\n    AND c.parsed_is_photon = v.is_photon\n    AND v.is_lts = TRUE)";
        let mut fixed = sql.to_string();
        let mut edits = collapse_lt02_autofix_edits_by_start(
            postgres_keyword_break_and_indent_edits(sql, 4, 4, IndentStyle::Spaces),
        );
        edits.sort_by(|left, right| right.start.cmp(&left.start));
        for edit in edits {
            if edit.start <= edit.end && edit.end <= fixed.len() {
                fixed.replace_range(edit.start..edit.end, &edit.replacement);
            }
        }

        assert!(
            fixed.contains(
                "        OR\n        -- PIPELINE: match on main_version + photon\n        (c.cluster_source = 'PIPELINE'\n            AND c.parsed_main_version = v.main_version\n            AND c.parsed_is_photon = v.is_photon\n            AND v.is_lts = TRUE)"
            ),
            "standalone ON block should indent nested AND conditions, got:\n{fixed}"
        );
    }

    #[test]
    fn postgres_inline_case_when_block_autofixes() {
        let sql =
            "SELECT\n    CASE WHEN a > 0\n        THEN 1\n        ELSE 0\n    END AS x\nFROM t";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "SELECT\n    CASE\n        WHEN a > 0\n            THEN 1\n        ELSE 0\n    END AS x\nFROM t"
        );
    }

    #[test]
    fn postgres_when_trailing_continuation_autofixes() {
        let sql = "SELECT\n    CASE\n        WHEN COALESCE(a,\n            b) > 0\n            THEN 1\n        ELSE 0\n    END AS x\nFROM t";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "SELECT\n    CASE\n        WHEN\n            COALESCE(a,\n            b) > 0\n            THEN 1\n        ELSE 0\n    END AS x\nFROM t"
        );
    }

    #[test]
    fn postgres_then_trailing_continuation_autofixes() {
        let sql = "SELECT\n    CASE\n        WHEN a > 0\n            THEN GREATEST(0,\n                a - 1)\n        ELSE 0\n    END AS x\nFROM t";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "SELECT\n    CASE\n        WHEN a > 0\n            THEN\n                GREATEST(0,\n                a - 1)\n        ELSE 0\n    END AS x\nFROM t"
        );
    }

    #[test]
    fn postgres_partition_by_multiline_continuation_autofixes() {
        let sql = "SELECT\n    SUM(cost_usd) OVER (PARTITION BY workspace_id,\n        usage_date) AS running_cost\nFROM t";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "SELECT\n    SUM(cost_usd) OVER (PARTITION BY\n        workspace_id,\n        usage_date) AS running_cost\nFROM t"
        );
    }

    #[test]
    fn postgres_select_star_with_continuation_autofixes() {
        let sql = "SELECT\n    *\nFROM (\n    SELECT *,\n        ROW_NUMBER() OVER (PARTITION BY id ORDER BY ts DESC) AS rn\n    FROM t\n) AS ranked";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "SELECT\n    *\nFROM (\n    SELECT\n        *,\n        ROW_NUMBER() OVER (PARTITION BY id ORDER BY ts DESC) AS rn\n    FROM t\n) AS ranked"
        );
    }

    #[test]
    fn postgres_case_when_multiline_expression_autofixes() {
        let sql = "SELECT\n    workspace_id,\n    table_full_name,\n    usage_date,\n    COUNT(*) AS query_count,\n    SUM(COALESCE(pruned_files_count, 0)) AS total_pruned_files,\n    SUM(COALESCE(read_files_count, 0)) AS total_read_files,\n    CASE WHEN SUM(COALESCE(pruned_files_count, 0) + COALESCE(read_files_count,\n        0)) > 0\n            THEN\n            SUM(COALESCE(pruned_files_count, 0))::numeric\n            / SUM(COALESCE(pruned_files_count, 0) + COALESCE(read_files_count,\n                0))\n    END AS pruning_ratio\nFROM query_files";
        let issues = run_postgres(sql);
        assert!(
            issues.iter().any(|issue| issue.autofix.is_some()),
            "expected LT02 autofix for CASE WHEN multiline expression"
        );
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "SELECT\n    workspace_id,\n    table_full_name,\n    usage_date,\n    COUNT(*) AS query_count,\n    SUM(COALESCE(pruned_files_count, 0)) AS total_pruned_files,\n    SUM(COALESCE(read_files_count, 0)) AS total_read_files,\n    CASE\n        WHEN\n            SUM(COALESCE(pruned_files_count, 0) + COALESCE(read_files_count,\n        0)) > 0\n            THEN\n            SUM(COALESCE(pruned_files_count, 0))::numeric\n            / SUM(COALESCE(pruned_files_count, 0) + COALESCE(read_files_count,\n                0))\n    END AS pruning_ratio\nFROM query_files"
        );
    }

    #[test]
    fn postgres_case_when_multiline_expression_generates_keyword_edits() {
        let sql = "SELECT\n    workspace_id,\n    table_full_name,\n    usage_date,\n    COUNT(*) AS query_count,\n    SUM(COALESCE(pruned_files_count, 0)) AS total_pruned_files,\n    SUM(COALESCE(read_files_count, 0)) AS total_read_files,\n    CASE WHEN SUM(COALESCE(pruned_files_count, 0) + COALESCE(read_files_count,\n        0)) > 0\n            THEN\n            SUM(COALESCE(pruned_files_count, 0))::numeric\n            / SUM(COALESCE(pruned_files_count, 0) + COALESCE(read_files_count,\n                0))\n    END AS pruning_ratio\nFROM query_files";
        let edits = postgres_keyword_break_and_indent_edits(sql, 4, 4, IndentStyle::Spaces);
        assert!(
            edits.iter().any(|edit| edit.replacement.contains('\n')),
            "expected postgres keyword-break edits for CASE WHEN multiline expression"
        );
    }

    #[test]
    fn postgres_partition_by_inline_case_autofixes() {
        let sql = "SELECT\n    ROW_NUMBER() OVER (\n        PARTITION BY CASE\n            WHEN a > 0\n            THEN 1\n            ELSE 0\n        END\n    ) AS rn\nFROM t";
        let issues = run_postgres(sql);
        assert!(!issues.is_empty());
        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
        assert_eq!(
            fixed,
            "SELECT\n    ROW_NUMBER() OVER (\n        PARTITION BY\n            CASE\n                WHEN a > 0\n                    THEN 1\n                ELSE 0\n            END\n    ) AS rn\nFROM t"
        );
    }
}
